Исключения (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-машина напечатала сообщение об ошибке и аварийно завершила свою работу. Что же содержится в этом сообщении?

  1. Сообщение о том, что произошла ошибка (исключение: слово Exception).
  2. Тип ошибки: ArrayIndexOutOfBoundsException и причина ошибки — индекс 2.
  3. список вызванных методов (в обратном порядке), имена файлов и номера строк.

Теперь понятно, что программа сломалась при попытке обратиться к элементу массива с несуществующим индексом, причём сломавшаяся часть находится в файле 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 хранится

  1. информация об исключении (например, в классе Exception хранится просто строка сообщения, в классе ArrayIndexOutOfBoundsException — индекс и т.п.)
  2. 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. Для booleanfalse. А вот классы инициализируются ссылкой на пустое место, т.е. 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 делятся на две большие группы:

  1. RuntimeException и его наследники;
  2. все остальные.

Обо всех бросаемых (и не обрабатываемых прямо в самом методе) исключениях из второй группы надо предупреждать в объявлении функции:

void foo() throws IOException {
    ...
    throw new IOException();
    ...
}

В противном случае компилятор выдаст ошибку. Теперь, если мы из какой-то функции попытаемся вызвать foo, мы или должны обернуть вызов в try {...} catch(IOException e) {...}, или прописать в объявлении вызывающего метода throws IOException. И т.д. Если метод может бросить несколько разных исключений второй группы, их надо объявлять через запятую:

void bar() throws IOException, OtherException {
    ...
}

Замечание: если вдруг метод по каким-то причинам бросает Error, это не нужно объявлять. Считается, что Error и RuntimeException могут сами возникнуть в любом месте программы.

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