null

Исключения в Java часть 1

Как показывает опыт многим начинающим в изучении программирования тяжело осознать тему исключений. На самом деле данная тема не является сложной. В этой статье и скорее всего нескольких следующих попытаюсь объяснить что и как. Для лучшего понимания темы нужно начать не с описание того что такое исключения, как они работают и как ими пользоваться, а понять какую проблему они решают, как эту проблему решали до сотворения исключений и чем такое решение было плохо. Очень полезно увидеть картину прошлого и понять именно причины возникновения того или иного механизма. Поэтому окунемся в некоторую историю.

Для начала возьмём цитату из Википедии

Обработка исключительных ситуаций (англ. exception handling) — механизм языков программирования, предназначенный для описания реакции программы на ошибки времени выполнения и другие возможные проблемы (исключения), которые могут возникнуть при выполнении программы и приводят к невозможности (бессмысленности) дальнейшей отработки программой её базового алгоритма. В русском языке также применяется более короткая форма термина: «обработка исключений».

Суть данной фразы заключается в следующем: исключения - механизм для обработки ошибок, которые возникают во время исполнения программы. Хорошо вот она наша проблема для решения которой создали данный механизм. А теперь посмотрим как решали эту проблему ранее. В языке программирования Си нет такого механизма, но как-то же люди там обрабатывают ошибки. Или не обрабатывают?  Давайте рассмотрим пример простой программы, состоящей из двух двух функций. Программа будет эмулировать покупку мороженого. В этой программе будет функция эмулирующая продавщицу makeIcecream, которая принимает на вход два аргумента: кол-во денег переданных продавщице money и тип запрашиваемого мороженого icecreamType. Данная функция возвращает число, характеризующее кол-во мороженного в граммах, которое вернула продавщица. Вторая же функция отвечает за Петю и называется wannaIcecream. Функция ничего не принимает и не возвращает, но именно в ней принимается решение о необходимости купить и съесть мороженое. 

Ниже представлен код с  этими функциями:

import java.io.Console;

public class Icecream {
    public static void main(String[] args) {
    Icecream i = new Icecream();
    i.wannaIcecream();
    }

    public void wannaIcecream() {
        Console console = System.console();
        String isWannaIcecream = console.readLine("Ты хочешь мороженое? (да/нет)\n");
        if (isWannaIcecream.equals("да")) {
            String moneyStr = console.readLine("Сколько рублей ты отдашь?\n");
            int money = Integer.valueOf(moneyStr);
            String icecreamType = console.readLine("Какое тебе мороженое нужно?\n");
            int icecream = makeIcecream(money, icecreamType);
            System.out.printf("Ура! Ты съел %d грамм %s мороженного!\n", icecream, icecreamType);
        }
    }

    public int makeIcecream(int money, String icecreamType) {
        return 10 * money;
    }
}

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

Простейшим вариантов является использовать особое возвращаемое значение, которое бы сигнализировало об ошибке. Давайте будем поступать следующим образом. Если возникла какая-то ошибка, то функция будет возвращаться отрицательное число. Например -1. А Вызывающая функция должна будет после вызова проверить, вернулся ли ей корректный результат или нет. Если же функция завершилась с ошибкой, то мы будем принимать решение что делать дальше. С новыми уточнениями код будет выглядеть следующим образом

import java.io.Console;

public class Icecream {
    public static void main(String[] args) {
    Icecream i = new Icecream();
    i.wannaIcecream();
    }

    public void wannaIcecream() {
        Console console = System.console();
        String isWannaIcecream = console.readLine("Ты хочешь мороженое? (да/нет)\n");
        if (isWannaIcecream.equals("да")) {
            String moneyStr = console.readLine("Сколько рублей ты отдашь?\n");
            int money = Integer.valueOf(moneyStr);
            String icecreamType = console.readLine("Какое тебе мороженое нужно?\n");
            int icecream = makeIcecream(money, icecreamType);
            if (icecream < 0 ) {
                icecreamType = console.readLine("Такого мороженого нет. Назови другой вкус\n");
                icecream = makeIcecream(money, icecreamType);
            }
            System.out.printf("Ура! Ты съел %d грамм %s мороженного!\n", icecream, icecreamType);
        }
    }

    public int makeIcecream(int money, String icecreamType) {
        if (!icecreamType.equals("шоколадное") && !icecreamType.equals("клубничное")) {
            return -1;
        }
        return 10 * money;
    }
}

Какие могут быть проблемы в таком решении? Во-первых, вопрос в том, а что же делать если может быть много разных ошибок? Например, если у вас не хватает денег на мороженое или у продавца нет сдачи для вас. В таком случае придётся под каждый тип ошибки выделять какое-то особое значение и в вызывающей функции это обрабатывать, опеределяя тип ошибки. В нашем примере это может выглядеть следующим образом: если функция вернула -1, то мороженого такого типа нет, -2 -- недостаточно денег для покупки, -3 -- нет сдачи. Вообще такое решение вполне приемлимо хоть и не очень удобно. Но главная проблема в такой ситуации в том, что не для каждой функции мы можем выделить из области значений результата значения, которые будут сигнализировать об ошибке. Это в нашем примере всё хорошо и результат функции makeIcecream по логике может возвращать только неотрицательные числа. Благодаря этому мы можем использовать неотрицательные числа как сигнал об ошибке. Но что делать если функция может возвращать любое значение из множества значений возвращаемого типа данных? Например результат функции y=x лежит в интервале от -∞ до +∞. Если мы скажем, что когда ф-ция возвращает -1, то это ошибка, тогда бы мы не смогли отличить корректный результат функции при аргументе -1, от случая когда функция завершилась с ошибкой. Конечно данный пример является достаточно искуственным ибо непонятно какие могут возникать ошибки при вычислении такой функции. Но, очевидно, что есть множество функций в которых может возникнуть ошибка, а возвращать они могут любое значения возвращаемого типа данных. Как быть в таком случае?

Можно изменить тип возвращаемого значения. Пусть возвращается не число, а класс, содержащий два поля: число и ошибку.  У этого решения также есть недостаток. Для начала взглянем на код такого решения.

import java.io.Console;

public class Icecream {
    public static void main(String[] args) {
    Icecream i = new Icecream();
    i.wannaIcecream();
    }

    public void wannaIcecream() {
        Console console = System.console();
        String isWannaIcecream = console.readLine("Ты хочешь мороженое? (да/нет)\n");
        if (isWannaIcecream.equals("да")) {
            String moneyStr = console.readLine("Сколько рублей ты отдашь?\n");
            int money = Integer.valueOf(moneyStr);
            String icecreamType = console.readLine("Какое тебе мороженое нужно?\n");
            IcecreamAndError result = makeIcecream(money, icecreamType);
            if (result.hasError) {
                icecreamType = console.readLine("Такого мороженого нет. Назови другой вкус\n");
                result = makeIcecream(money, icecreamType);
            }
            int icecreamWeight = result.weight;
            System.out.printf("Ура! Ты съел %d грамм %s мороженного!\n", icecreamWeight, icecreamType);
        }
    }

    public IcecreamAndError makeIcecream(int money, String icecreamType) {
        IcecreamAndError result = new IcecreamAndError();
        if (!icecreamType.equals("шоколадное") && !icecreamType.equals("клубничное")) {
            result.hasError = true;
            return result;
        }
        result.weight = 10 * money;
        return result;
    }

    public class IcecreamAndError {
        public int weight;
        public boolean hasError = false;
    }
}

Что плохого в таком решении? Проблема в том, что мы порождаем новый тип данных только для того чтобы дополнительно вернуть ошибку. Также усложняется код формирования ответа. Плюс те функции, которые будут вызывать makeIcecream в которой может произойти ошибка должны будут извлекать необходимый для них ответ из внешнего объекта. А как же иначе спросят некоторые? Ведь не можем же мы отлавливать ошибки не написав ни одной дополнительной строки кода.  Мысль верна. Но ошибки возникают редко и хотелось бы иметь возможность проигнорировать их. В  примере кода где при ошибке возвращалось отрицательное число, можно было бы просто не делать проверку и для большинства случаев всё бы было хорошо. У впечатлительного читателя может возникнуть вопрос: "Как так? Это же ошибка? Что значит взять и не обработать?". Да для конкретно этого примера это кажется странным. Возникнет день, когда Петя отдаст деньги и попросит ванильное мороженое, а получит отрицательное количество грамм. Но есть ситуации, когда хотелось бы не писать дополнительный код, а просто позволить иногда ошибкам происходить. Также есть ещё одна проблема. Что если в нашем действии участвует не две функции, а больше? А что тогда изменится спросите вы? Для того чтобы это понять давайте рассмотрим  пример из человеческой жизни. 

Представим, что вы находитесь в крепости и сегодня вы заступили на дежурство. Вы дозорный на стене. И ваша задача следить за тем, что происходит снаружи. И вот вы в очередной раз окидываете взглядом бескрайние просторы вокруг вашего замка и вдалеке видите приближающееся войско. Такая ситуация явно не является рядовой и требует какого-то решения. Но проблема в том, что принятие решения не входит в ваши компетенции и ваша задача сообщить о возникшей проблеме тому, кто будут принимать решения. Но тут также не факт, что у вас есть непосредственный доступ к тому, кто принимает решение. Что мы имеем? У нас есть исключительная ситуация (ошибка), которая возникла и была выявлена вами как дозорным на стене. Но обработать эту ошибку должен главный вояка в крепости. Чтобы информация об ошибки достигла до того кто принимает решение ей нужно пройти цепочку других людей, которые будут передавать информацию об ошибке выше по уровню. Вы крикните что враги приблежаются ближайшему к лестнице человеку. Он сообщит это тем, кто стоит внизу. Они передадут это сообщение кому-то ещё. Тот побежит в казармы в главному, который и примет решение выводить ли войска, или же закрывать ворота. В программировании тоже возможна такая ситуация. Здесь в качестве людей будут выступать функции. И наш пример можно описать в терминах программирования следующим образом: есть  функции (A,B,C,D,E), которые вызывают друг друга и выстраиваются в какой-то граф или для упрощения в цепочку. Предположим, что она выглядит следующим образом A->B->C->D->E, т.е. A вызывает B, B вызывает C, C вызывает D и т.д. Получается, что если в функции E возникнет ошибка, а принимать решение о том, как её обработать должна функция B,  то внутри функции C и D должны быть проверки на наличие ошибок и при возникновении ошибок необходимо останавливать дальнейшую работу функции и передавать выше по цепочке информацию о возникшей ошибки. В такой ситуации мы вынуждены писать много кода для проверки и пробрасывания ошибок вверх. А если ещё и нет возможности выделить в возвращаемом типе данных какие-то значения сигнализирующие об ошибке, то мы ещё больше усложняем код нашей программы.  Именно для того чтобы упростить механизм обработки и передачи вверх по коду до функции, осуществляющей обработку таких ошибок и были придуманы исключения. 

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