Обработка ошибок. Обработка исключительных ситуаций.
Как правило, если программа работает неправильно, то в ней есть ошибка. И даже если мы полностью уверены в корректности работы нашей программы, в обычной ситуации мы никак не защищены от того, что пользователь задаст неверные входные данные, и от ошибок, связанных с этим. Например, мы пишем функцию, считающую частное двух чисел:
Пример №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.