Исключения (Exceptions)
Введение
До сих пор при написании программ мы считали, что все данные 'правильные', и не проверяли никакие получаемые параметры. Теперь представим, что мы вдруг захотим сложить две матрицы размерами 2×2 и 3×3:
public class Main { public static void main(String[] args) { SquareMatrix m1 = new SquareMatrix(3); SquareMatrix m2 = new SquareMatrix(2); m1.sum(m2); } }
Что же случится, если мы запустим эту программу?
example1 $ java Main Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2 at Matrix.add(Matrix.java:76) at SquareMatrix.sum(SquareMatrix.java:43) at Main.main(Main.java:5)
Java-машина напечатала сообщение об ошибке и аварийно завершила свою работу. Что же содержится в этом сообщении?
- Сообщение о том, что произошла ошибка (исключение: слово
Exception
). - Тип ошибки:
ArrayIndexOutOfBoundsException
и причина ошибки — индекс 2. - список вызванных методов (в обратном порядке), имена файлов и номера строк.
Теперь понятно, что программа сломалась при попытке обратиться к элементу массива с несуществующим индексом, причём сломавшаяся часть находится в файле Matrix.java на строке 76.
Если мы посмотрим, что написано в файле Matrix.java
около строке 76, вот что мы увидим:
protected void add(Matrix m2) { for (int i = 0; i < myValues.length; i++) for (int j = 0; j < myValues[i].length; j++) myValues[i][j] += m2.myValues[i][j]; // ← строка 76 }
И действительно, когда мы попытались к матрице размером 2×2 прибавить матрицу размером 3×3, программа попыталась обратиться к несуществующей (третьей, т.е. строке с индексом 2) строке матрицы.
Другой понятный пример ошибки — это написать что-то в этом духе:
int i, j, k; i = 10; j = 0; k = i/j; // эта строчке вызовет ArithmeticException: / by zero
Java — объектно-ориентированный язык. Поэтому в момент ошибки создается и, как говорят, бросается (throw) объект специального класса — Exception или его класса-наследника. В частности, один из его наследников называется ArrayIndexOutOfBoundsException
. Потом исполнение программы постепенно прерывается.
Отступление о стеке вызовов
Рассмотрим такую программу на неком языке:
int i = 10; bar(int a) { // какой-то код ... } foo(int x, int y, int z) { // какой-то код ... bar(x); // ещё код ... } main() { i = i + 1; foo(2, 3, 4); // код ... foo(5, 6, 7); }
В момент вызова функции (например, foo) надо запомнить адрес, куда после её завершения надо вернуть управление. Ещё надо запомнить переданные в эту функцию параметры. Глобальные переменные для этого не подходят, ведь никто не знает, сколько вложенных вызовов (main→foo→bar→...) будет. Вместо этого используется специальный стек, называемый стек вызовов.
При вызове любой функции в него помещается адрес возврата и значения параметров, а затем выполнение переходит к тексту (коду) этой функции. Когда она доходит до возврата, специальная команда извлекает из стека параметры, адрес возврата и переходит на него.
В языке Java в стеке хранится информация (точнее ссылка на информацию), достаточная для получения точки вызова в коде — названия функции, имени файла, номера строки.
Замечание на полях: существует доступный программисту механизм, позволяющий узнать содержимое стека.
Возвращаемся к Exceptions
В объекте типа Exception
хранится
- информация об исключении (например, в классе Exception хранится просто строка сообщения, в классе
ArrayIndexOutOfBoundsException
— индекс и т.п.) - stacktrace — список из стека вызовов.
Отступление о запуске программ
В чем пока для нас заключается польза от исключений? В том, что мы можем точно узнать место и тип ошибки. Однако JIT-компилятор в процессе выполнения может оптимизировать код. Поэтому иногда, stacktrace может вместо информации о файле/строке просто выдать знак вопроса. А иногда просто ложную информацию. Поэтому, в некоторых случаях локализации ошибки может помочь отключение оптимизатора. До сих пор мы запускали программу просто командой
$ java Main
Java-машине можно указывать опции. Есть небольшой набор стандартных опций, которые должны поддерживать все, кто хочет называться гордым именем Java. А все остальные опции начинаются с -X
. Например, есть такая нестандартная (правда её поддерживают все известные реализации Java-машин) опция -Xint
(от слова interpreter — интерпретатор}, которая указывает Java-машине только интерпретировать код).
Ошибки, которые мы умеем делать
ArrayIndexOutOfBoundsException
Выход за границы массива. С этим все понятно.
ArithmeticException
Ошибка целочисленной арифметики. Например, деление на ноль.
ClassCastException
Ошибка приведения типов. Пример:
Matrix m = new Matrix(3, 3); SquareMatrix s = (SquareMatrix)m;
Вторая строчка вызовет исключение, поскольку хотя матрица m
является квадратной, она не является представителем класса SquareMatrix
.
NullPointerException
Отступление: об инициализации переменных и полей классов.
Допустим у нас есть класс X
:
class X { int myN; boolean myB; int[] myA; X myX; // ..... }
И есть функция foo
:
void foo() { int n; boolean b; int[] a; X x = null; // ......... }
Локальные переменные не инициализируются. Попытка обратиться к любой из них до того, как мы присвоили ей значение, вызовет ошибку, причём на этапе компиляции. А вот поля в классе инициализируются значениями по-умолчанию. Для числовых типов — это 0. Для boolean
— false
. А вот классы инициализируются ссылкой на пустое место, т.е. null
.
Собственно об ошибке.
Matrix m = null; Matrix m2 = new Matrix(); m.sum (m2); // в этот момент случится ошибка
Вероятно, это наиболее распространённый вид ошибок.
Exceptions с точки зрения программиста
Я сам так хочу...
Начнём с примера. Как мы уже видели, попытка сложить две матрицы разного размера может привести к ошибке. С другой стороны понятно, что если попытаться прибавить матрицу большего размера к матрице меньшего, ошибки не произойдет, поскольку индекс суммирования за границы массива не выйдет. Да и потом, даже та ошибка, которую мы получили имеет лишь косвенное отношение к матрицам. Очень хочется научиться самим сообщать об ошибках. Как уже говорилось, ошибку нужно создать и бросить:
public class SquareMatrix extends Matrix { // ...... SquareMatrix sum(SquareMatrix m) { // проверка размеров матриц if (myValues.length != m.myValues.length) { Exception e = new RuntimeException(); throw e; } // остальной код метода.... } //..... }
А теперь чуть-чуть модифицируем пример. Во-первых, можно писать чуть-чуть короче:
// ... throw new RuntimeException(); // ...
А ещё можно сказать, в чем собственно дело:
// ... throw new RuntimeException("Matrix dimensions differ!"); // ...
Как ловить то, что бросают?
void main(String[] args) { ...... foo(); ...... } void foo() { ...... bar(); ...... } void bar() { ...... throw new RuntimeException(); ...... }
После создания Exception
, функции начинают аварийно завершаться, пока не завершится программа. Или пока его не поймают.
void foo() { ...... try { ...... bar(); // в процессе выполнения bar() будет брошен RuntimeException // этот код не будет выполняться ...... } catch (Exception e) { // после броска, управление будет передано сюда. ...... } // а потом сюда. ...... }
При броске исключения, начнёт разматываться стек. Потом, попав в блок "try
", управление перескочит в "catch
", будет выполняться код из этого блока, а потом продолжит выполняться остальная программа после блоков try-catch
.
Что можно сделать с пойманным Exception?
Его можно просто проигнорировать. Например вместо меток можно использовать такой фрагмент кода:
try { ...... throw new RuntimeException(); ..... } catch (Exception e) { } ......
Можно снова бросить тот же Exception:
try { ...... throw new RuntimeException(); ..... } catch (Exception e) { throw e; }
Если мы поймали Exception, раскрутка стека останавливается, исключение дальше не идёт, и stacktrace не печатается. Об этом мы должны позаботиться сами, вызвав метод Exception.printStackTrace().
Ещё один почти синтетический пример: если нам захочется напечатать stacktrace не прерывая выполнение программы, можно написать так:
Exception e = new Exception(); e.printStackTrace();
Написав "catch (Exception e)
", мы тем самым говорим системе, что мы хотим ловить все исключения. Как правило, какие-то действия заключаются в блок "try-catch
", если программист ожидает, что в этом месте какое-то из действий может бросить какое-то (как правило конкретное) исключение. При этом, вероятно, нежелательно реагировать на другие виды ошибок. Правильнее бросать и ловить максимально конкретное исключение. Бросать всегда непосредственно RuntimeExpection
— очень плохая идея, очень плохой стиль программирования. Например в следующем примере будут ловиться только арифметические исключения (т.е. ArithmeticException
и его наследники).
try { // какой-то код, который может кидать исключения. ...... } catch (ArithmeticException ae) { // обработать арифметическую ошибку ...... }
Если нужно ловить несколько разных типов исключений можно поставить несколько catch
-блоков подряд:
try { // код ...... } catch (ArithmeticException e) { ...... } catch (MatrixException e) { ...... } catch (Exception e) { // поймать все остальные исключения }
Порядок catch'ей важен. Пытаться поймать родителя до потомка — синтаксическая ошибка. Например, нельзя написать catch (Exception e) {...}
до catch (ArithmeticExpeption e) {...}
, поскольку Excepton
— предок класса ArithmeticException
.
Блок finally
Опять начнём с примера. На этот раз пример будет на смеси java и русского. Будем работать с файлами. Почти все или даже все функции, работающие с файлами, могут бросатьIOException
.
try { открыть файл; прочитать; если (условие) { выйти из функции; } и т.п. } catch (IOException e) { ... }
У приведённого фрагмента кода одна основная проблема: он не закрывает файл. Добавим этот код:
try { открыть файл; прочитать; если (условие) { закрыть файл; выйти из функции; } и т.п. закрыть файл; } catch (IOException e) { закрыть файл; ... }
Итого, код закрыть файл
продублирован три раза. Если мы усложним пример, дублированного кода станет ещё больше. Как раз для подобных ситуаций (когда какое-то действие нужно сделать в любом случае, независимо от того, как произойдет выход из некого куска кода) в Java существует последняя часть конструкции try-catch-finally:
try { открыть файл; прочитать; если (условие) { выйти из функции; } и т.п. } catch (IOException e) { ... } finally { закрыть файл; }
Код из блока finally
выполнится в любом случае: при нормальном выходе из try
, после обработки исключения или при выходе по команде return
.
Код в блоке finally
должен быть максимально простым: например, если внутри блока finally
будет брошено какое-либо исключение или просто встретится оператор return
, брошенное в блоке try
исключение (если таковое было брошено) будет забыто. Некоторые языки (например, C#
) запрещают использовать в блоке finally
некоторые языковые конструкции.
Замечание: можно писать просто try-finally
:
try { // какой-то код // ...... } finally { // уборка мусора и т.п. }
Виды ошибок
Дерево наследования для класса Throwable
В языке Java для всего, что можно бросить есть специальный класс — Throwable
. Дерево его наследников см. рисунок. Класс Error
и его подклассы предназначены для системных ошибок. Свои собственные классы-наследники для Error
писать (за очень редкими исключениями) не нужно, а ловить их не принято. Как правило это действительно фатальные ошибки, пытаться обработать которые довольно бессмысленно.
Все исключения в Java делятся на две большие группы:
RuntimeException
и его наследники;- все остальные.
Обо всех бросаемых (и не обрабатываемых прямо в самом методе) исключениях из второй группы надо предупреждать в объявлении функции:
void foo() throws IOException { ... throw new IOException(); ... }
В противном случае компилятор выдаст ошибку. Теперь, если мы из какой-то функции попытаемся вызвать foo
, мы или должны обернуть вызов в try {...} catch(IOException e) {...}
, или прописать в объявлении вызывающего метода throws IOException
. И т.д. Если метод может бросить несколько разных исключений второй группы, их надо объявлять через запятую:
void bar() throws IOException, OtherException { ... }
Замечание: если вдруг метод по каким-то причинам бросает Error
, это не нужно объявлять. Считается, что Error
и RuntimeException
могут сами возникнуть в любом месте программы.
Замечание 2: теперь мы можем точно сформулировать, из чего состоит сигнатура метода: название, типы и порядок параметров и список бросаемых исключений.