Обработка ошибок. Обработка исключительных ситуаций.

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

Пример №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'ы. Тем не менее, имеются некоторые различия в их использовании.

Есть несколько “школ”, утверждающих как можно пользоваться исключениями:

  1. Как было указано ранее – то есть если возникает желание бросить exception, это можно беспрепятственно осуществить.
  2. В заголовке каждой функции, которая может бросить exception, необходимо перечислять все виды исключений, которые она может бросать:

    Пример 2:

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

Очевидно, обе “школы” имеют свои плюсы и минусы. Основное преимущество второго способа – безопасность. Мы видим, что метод может бросать исключение, и не забудем про него.

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

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

Виды ошибок.

В языке Java для всего, что можно бросить, есть специальный класс — Throwable. Дерево наследования класса Throwable изображено ниже:

Класс Error и его подклассы предназначены для системных ошибок. Свои собственные классы-наследники для Error писать (за очень редкими исключениями) не нужно, а ловить их не принято. Как правило, это действительно фатальные ошибки, пытаться обработать которые довольно бессмысленно.

Все исключения в Java делятся на несколько больших групп:

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

Считается, что 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.