Обработка ошибок. Обработка исключительных ситуаций.
Как правило, если программа работает неправильно, то в ней есть ошибка. И даже если мы полностью уверены в корректности работы нашей программы, в обычной ситуации мы никак не защищены от того, что пользователь задаст неверные входные данные, и от ошибок, связанных с этим. Например, мы пишем функцию, считающую частное двух чисел:
Пример №1:
int calculate(int a, int b) {
if (b != 0) {
return a / b;
} else {
// Проблема. Что писать здесь???
}
}
Предполагается, что функция calculate ничего не знает об интерфейсе программы, вызывающей ее, в частности не знает, куда ей писать сообщение об ошибке и что делать в связи с этим дальше. Подобная ситуация встречается очень часто в связи с концепцией ООП о том, что интерфейс и логика программы должны быть разделены. Хочется отметить, что данная проблема бывает, действительно, только тогда, когда есть разные части программы и та часть, в которой происходит ошибка, не знает, как ее обработать.
Существует несколько возможных решений этой проблемы. Мы рассмотрим два: первое активно используется в необъектно-ориентированных языках, а второе, напротив, используется в объектно-ориентированных языках программирования, в частности в Java.
Решение, используемое в необъектно-ориентированных языках:
Создадим специальную статическую переменную error. Тогда пример №1 можно будет разрешить следующим образом:
int calculate(int a, int b) {
if (b != 0) {
return a / b;
} else {
Error.error = 18; // 18 - код ошибки деления на 0
return 0;
}
}
Тогда пользователь нашей функции сможет посмотреть значение переменной error и предпринять необходимые действия в связи с этим. Тем не менее, такой способ имеет очевидные недостатки: каждый раз после вызова нашей функции необходимо проверять, не появилась ли ошибка, и очень велик риск того, что пользователь нашей функции с легкостью забудет сделать эту проверку. Помимо этого, эта переменная могла как-то по-своему использоваться в другой части программы, в этом случае информация о появлении ошибки может потеряться.
Решение, используемое в Java:
Исключения (Exceptions).
Что такое exception? Конструкция throw.
Когда появляется ошибка вроде необходимости деления на 0, то на самом деле нам надо нашу функцию в этом месте прервать, так как дальнейшее ее выполнение бессмысленно. Но как говорилось выше, эту функцию вызвала какая-то другая функция, которую тоже надо прервать и так далее. Таким образом, мы хотим прервать работу функций, но вместе с тем получить информацию о том, где и какая ошибка произошла. Для этого в Java можно поступить следующим образом:
int calculate(int a, int b) {
if (b != 0) {
return a / b;
} else
throw new RuntimeException();
}
}
В этот момент создастся новый объект типа RuntimeException. Бросая его (throw), функция прерывается (никаких значений она при этом вообще не возвращает), прерывается и та функция, которая ее вызвала и т.д. Условно говоря, этот RuntimeException поднимается вверх по стеку вызовов функций и в него записывается, где мы вывалились (из какого метода, в какой строчке и с какими параметрами). Когда произойдет вылет из функции main, работа программы завершится и будет выведен stacktrace – информация о том, работа каких функций и в каких местах привела к ошибке.
Уже от такого использования есть некоторая выгода. Предположим, что по логике программы в функцию calculate первого примера никак не может в качестве параметра поступить значение b = 0 (мы в этом уверены, так как где-то в другом месте сделали проверку на это). Тем не менее, написав throw в тех точках программы, до которых по нашему предположению никак дойти нельзя, нам будет легче отыскивать «баги» в программе – если мы все же получим RuntimeException, то будем знать, что ошибка есть, и нам будет легче понять, в каких местах ее надо искать.
Информация о характере ошибки.
При создании объекта типа RuntimeException мы можем передать в конструктор информацию о характере ошибки, например, можно написать так:
int calculate(int a, int b) {
if (b != 0) {
return a / b;
} else {
throw new RuntimeException("Division by zero");
}
}
Тогда при броске этого исключения, мы помимо всего прочего, получим еще сообщение от этого exception'a. Возможно, эта информация сможет помочь нам в исправлении ошибки.
Тем не менее иногда случается так, что JIT-компилятор в процессе выполнения может оптимизировать код. Из-за этого иногда, stacktrace вместо информации о файле/строке места ошибки может просто выдать знак вопроса. А иногда просто ложную информацию.
Java сама бросает исключения.
Одной из самых распространенных ошибок при программировании является попытка обратиться к нестатическому члену класса, в то время как экземпляр класса (объект) еще не создан. Ранее упоминалось, что при таком действии программа сломается. На самом же деле, в этом месте будет брошен NullPointerException (наследник RuntimeException). Java сама вставляет огромное количество проверок. Таким образом, каждый раз, когда мы обращаемся к полю или методу объекта, то Java сама сначала делает проверку на равенство этого объекта null и лишь в противном случае осуществляет обращение. Иначе – кидает исключение. Аналогично, при целочисленном делении на 0, Java также кинет исключение. Вообще в любом месте, где в обычных языках могла бы произойти ошибка, в Java ошибки не произойдет, а сначала будет сделана проверка на возможность осуществления операции и в случае невозможности бросается exception. Таким образом, из-за этого программа работает немного дольше, но зато мы можем быть уверены, что java-программа не сломается в неожиданном месте.
Часто встречающиеся ошибки.
ArrayIndexOutOfBoundsException – Выход за границы массива.
int[] array = new int[10]; array[20] = 0; // здесь будет брошен ArrayIndexOutOfBoundsException
ArithmeticException – Ошибка целочисленной арифметики. Например, деление на ноль.
int a = 10 / 0; // здесь будет брошен ArithmeticException
ClassCastException – Ошибка приведения типов. Всякий раз при приведении типов делается проверка на возможность приведения (проверка осуществляется с помощью instanceof. Примечание: null не является instanceof какого-либо класса).
int x = toInt("ABC");
...
int toInt(Object a) {
Integer x = (Integer) a; // в данном случае будет брошен ClassCastException
return x.intValue();
}
NullPointerException – Ошибка обращения к полю или методу null'а.
Bar bar; bar.foo(); // здесь будет брошен NullPointerException
RuntimeError – класс-наследник.
В действительности, все классы, соответствующие этим часто встречающимся ошибкам, являются наследниками класса RuntimeException. Непосредственно RuntimeException никогда не бросается, а бросаются лишь его наследники.
Конструкция catch.
Совершенно точно, exception’ы были бы не столь необходимы, если бы их можно было только бросать и нельзя было бы ловить.
После создания exception’а, функции начинают аварийно завершаться, пока не завершится программа. Или пока его не поймают. Пример:
void bar() {
......
throw new RuntimeException();
......
}
void foo() {
......
try {
......
bar(); // в процессе выполнения bar() будет брошен RuntimeException
// этот код не будет выполняться
......
} catch (RuntimeException e) {
// после броска исключения типа RuntimeException, управление будет передано сюда
......
}
// а потом сюда.
......
}
При броске исключения, начнёт разматываться стек. Потом, попав в блок "try", управление перескочит в соответствующий "catch", будет выполняться код из этого блока, а потом продолжит выполняться остальная программа после блоков try-catch.
Что можно делать с пойманным exception'ом.
Пойманный exception можно просто проигнорировать. Например, вместо меток можно использовать такой фрагмент кода:
try {
......
throw new RuntimeException();
.....
} catch (RuntimeException e) {
}
......
Также поймав exception можно снова бросить его:
try {
......
throw new RuntimeException();
.....
} catch (RuntimeException e) {
throw e;
}
Печать stacktrace’а.
Если мы поймали exception, раскрутка стека останавливается, исключение дальше не идёт, и stacktrace не печатается. Об этом мы должны позаботиться сами, вызвав метод Exception.printStackTrace().
Если же нам хочется напечатать stacktrace, не прерывая выполнение программы, можно написать так:
new RuntimeException().printStackTrace();
Что же ловится на самом деле? И что следует ловить?
Написав "catch (RuntimeException e)", мы тем самым говорим системе, что мы хотим ловить все исключения, которые явяются instanceof RuntimeExcepton.
Обычно какие-то действия заключаются в блок "try-catch", если программист ожидает, что в этом месте некоторое действие может бросить какое-то (как правило, конкретное) исключение. При этом, вероятно, нежелательно реагировать на другие виды ошибок. Правильнее бросать и ловить максимально конкретное исключение. Бросать всегда непосредственно RuntimeExpection — очень плохая идея, очень плохой стиль программирования.
В следующем примере будут ловиться только исключения приведения типа (т.е. ClassCastException и его наследники):
try {
b = (Bar) a;
int c = d / e[10].foo();
} catch (ClassCastException cce) {
cce.printStackTrace();
}
Можно ловить несколько разных типов исключений.
Если нужно ловить несколько разных типов исключений, можно поставить несколько catch-блоков подряд:
try {
// код
......
} catch (ArithmeticException e) {
......
} catch (ClassCastException e) {
......
} catch (RuntimeException e) {
// поймать все остальные исключения типа RuntimeException
}
Порядок catch'ей важен. Пытаться поймать родителя до потомка — синтаксическая ошибка. Например, нельзя написать:
try {
......
} catch (RuntimeException e) {
......
} catch (ArithmeticException e) {
......
}
поскольку RuntimeException — предок класса ArithmeticException.
Когда можно и нужно бросать исключения.
Бросание exception достаточно долгая операция (создается новый объект), поэтому если можно обойтись проверками напрямую без создания исключения, то именно так и надо делать. Никогда не следует создавать исключение, если ошибка является локальной и ее можно обработать сразу.
Единственная возможная ситуация, когда можно и нужно создавать exception, появляется в том случае, если место, где происходит ошибка и место, где происходит обработка ошибки, находятся в разных частях программы. Например, в следующей ситуации:
interf() {
try {
bar();
} catch (RuntimeException e) {
e.getMessage(); // getMessage выдает сообщение, созданное в конструкторе данного exception’а
}
}
bar() {
......
if (......) {
throw new RuntimeException(); // здесь невозможно обработать этот exception, так как здесь ничего с интерфейсом делать нельзя
} else {
......
}
}
Конструкция finally.
Иногда при использовании try / catch бывают такие ситуации, что в независимости от того в какую ветку этой конструкции мы попали, нам надо сделать в конце одно и тоже действие. Типичной ситуацией подобного рода является работа с файлами в Java.
try {
открыть файл;
прочитать;
если (условие) {
закрыть файл;
выйти из функции;
}
и т.п.
закрыть файл;
} catch (IOException e) {
закрыть файл;
...
}
Итого, код закрыть файл продублирован три раза. Если мы усложним пример, дублированного кода станет ещё больше. Как раз для подобных ситуаций (когда какое-то действие нужно сделать в любом случае, независимо от того, как произойдет выход из некого куска кода) в Java существует последняя часть конструкции try-catch-finally:
try {
открыть файл;
прочитать;
если (условие) {
выйти из функции;
}
и т.п.
} catch (IOException e) {
...
} finally {
закрыть файл;
}
Код из блока finally выполнится в любом случае: при нормальном выходе из try, после обработки исключения или при выходе по команде return.
Код в блоке finally должен быть максимально простым: например, если внутри блока finally будет брошено какое-либо исключение или просто встретится оператор return, брошенное в блоке try исключение (если таковое было брошено) будет забыто.
Замечание: можно писать исключительно блоки try-finally:
try {
// какой-то код
// ......
} finally {
// уборка мусора и т.п.
}
Как можно пользоваться исключениями.
Исключения не являются какой-то уникальной особенностью Java. Во многих других языках также есть exception'ы. Тем не менее, имеются некоторые различия в их использовании.
Есть несколько “школ”, утверждающих как можно пользоваться исключениями:
- Как было указано ранее – то есть если возникает желание бросить exception, это можно беспрепятственно осуществить.
- В заголовке каждой функции, которая может бросить exception, необходимо перечислять все виды исключений, которые она может бросать:
Пример 2:
void foo() throws IOException { ... throw new IOException(); ... }
Очевидно, обе “школы” имеют свои плюсы и минусы. Основное преимущество второго способа – безопасность. Мы видим, что метод может бросать исключение, и не забудем про него.
Основной недостаток – нам постоянно приходится писать в заголовках один и тот же список бросаемых исключений.
В Java используется синтез этих двух идей. Некоторые виды исключений необходимо описывать в заголовке методов, другие же можно опускать.
Виды ошибок.
В языке Java для всего, что можно бросить, есть специальный класс — Throwable. Дерево наследования класса Throwable изображено ниже:

Класс Error и его подклассы предназначены для системных ошибок. Свои собственные классы-наследники для Error писать (за очень редкими исключениями) не нужно, а ловить их не принято. Как правило, это действительно фатальные ошибки, пытаться обработать которые довольно бессмысленно.
Все исключения в Java делятся на несколько больших групп:
RuntimeExceptionи его наследникиErrorи его наследники- все остальные
Считается, что Error и RuntimeException могут сами возникнуть в любом месте программы, и их объявлять в заголовках методов не надо.
Обо всех бросаемых (и не обрабатываемых прямо в самом методе) исключениях из третьей группы надо предупреждать в объявлении функции (как в примере №2, IOException не является наследником RuntimeException).
В противном случае компилятор выдаст ошибку. Теперь, если мы из какой-то функции попытаемся вызвать foo, мы или должны обернуть вызов в try {...} catch(IOException e) {...}, или прописать в объявлении вызывающего метода throws IOException. Если метод может бросить несколько разных исключений третьей группы, их надо объявлять через запятую:
void bar() throws IOException, OtherException {
...
}
Какие exception’ы следует бросать.
По возможности надо кидать более конкретный тип исключения. Поэтому есть смысл создать свой собственный вид exception и ловить его (чтобы случайно не поймать «не свой» exception):
public class FooException extends RuntimeException {
public FooException(String message) {
super(message);
}
}
Также хочется отметить, что более одного “собственного exception’а” создавать почти никогда не надо. В любых даже очень больших проектах почти всегда хватает одного “своего” вида exception.
