Польза проверяемых исключений весьма сомнительна, а вред очевиден +37


Время споров прошло.
— начало главы «Используйте непроверяемые исключения» книги «Чистый код» Роберта Мартина.

Как бы ни хотел Майк Физерс (автор цитаты в эпиграфе) поставить точку в споре «checked vs unchecked exceptions», сегодня, более чем пять лет спустя, мы вынуждены признать, что эта тема до сих пор остается «холиварной». Сообщество программистов разделилось на два лагеря. Обе стороны приводят веские аргументы. Среди тех и других есть разработчики экстра-класса: Bruce Eckel критикует концепцию проверяемых исключений, James Gosling — защищает. Похоже, этот вопрос никогда не будет окончательно закрыт.

Пять лет назад совет отказаться от проверяемых исключений вызвал у меня недоумение и возмущение. Учебники по Java объясняли, когда их использовать, а тут такое… У меня совсем не было опыта разработки на Java, поэтому я мог только принять на веру одно из двух, и я отверг совет из «Чистого кода». Я никогда так не ошибался в своей жизни.

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

В общем, это мой первый пост, не судите строго добро пожаловать под кат.

Как было задумано


Единственная причина существования разных типов исключений — разные способы их обработки:

  • Штатные ошибки, обработка которых является частью бизнес логики. Пример: пользователь ввел неверный пароль — отобразить соответствующее сообщение и попросить ввести снова. Для таких ситуаций, по замыслу авторов Java, нужно использовать checked exceptions.
  • Непредвиденные ошибки, в случае которых мы говорим пользователю «у нас что-то сломалось», логируем stack trace и открываем ticket в багтрекере. В этом случае речь идет о непроверяемых исключениях.

Раз уж штатные ситуации обязательно должны быть обработаны, Гослинг и компания решили возложить контроль на компилятор: мол, забыли обработать — ошибка, запускать нельзя. Как это работает, мы знаем: либо ловим исключение, либо указываем его в списке throws.

Но что получается, когда проверяемое исключение обрабатывается на много уровней выше? Его придется добавить в сигнатуры многих методов. И чем больше их появляется, тем «веселее». Не так уж удобно, но, может быть, это необходимое зло?

Ради чего нам предлагают терпеть эти навязчивые «throws», неудобства с лямбдами в Java 8 и т.п.? Рассмотрим аргументы за checked exceptions:

  • «Механизм проверяемых исключений гарантирует, что все штатные ситуации будут обработаны. Это позволяет создавать более надежное ПО.»
  • «В языках без checked exceptions разработчики могут не указать возможные исключения в сигнатуре метода, в результате не всегда понятно, каких подвохов от него ждать. В Java компилятор этого не допустит.»

Теперь возражения.

To check or not to check?


Рассмотрим сигнатуру конструктора FileInputStream:

public FileInputStream(File file) throws FileNotFoundException

Разработчик решил, что отсутствие файла — нормальная ситуация при попытке его открыть, поэтому он заботливо выбрасывает проверяемое исключение FileNotFoundException, чтобы мы точно его обработали.

Проблема здесь в том, что в большинстве случаев мы вообще не хотим ничего обрабатывать. Например, наше веб-приложение пытается открыть файл конфигурации, без которого оно не сможет нормально работать, а его нет. Для нас FileNotFoundException такая же фатальная ошибка, как какой-нибудь NPE, но мы вынуждены объявить его в десятках мест выше только для того, чтобы где-то на самом верху поймать и залогировать:

catch(Exception e){
    LOGGER.error("Fatal error", e);
    return new Response(500, "Oops, unexpected error on server");
}

Смотрите, обработчику же пофиг, какое исключение к нему придет. Тогда в чем здесь польза «проверяемости»?

— Но ведь все исключения ловить неправильно, надо ловить только проверяемые!
— А RuntimeException молча глотать?
— Но их сервер сам залогирует и вернет 500!
— Ну так если FileNotFoundException обернуть в непроверяемое, будет то же самое, только его не надо дополнительно ловить руками и везде прописывать в throws.

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

Но что если это штатная ситуация с особой обработкой?


Допустим, некоторые ошибки мы все же обрабатываем: пришел ValidationException — возвращаем статус 400 с сообщением, AccessDeniedException — 403, ResourceNotFoundException — 404 и т.д.

Если они все unchecked, их обработка элементарна:

catch(ValidationException e){
    return new Response(400, e.getMessage());
}
catch(AccessDeniedException e){
    return new Response(403, e.getMessage());
}
catch(ResourceNotFoundException e){
    return new Response(404, e.getMessage());
}

Сделаем все эти исключения проверяемыми.

Проблемы начинаются в достаточно больших приложениях: обрабатываемых исключений становится много, и метод верхнего слоя легко может обрасти списком throws с десятком исключений. Скорее всего, никому из разработчиков это не нравится, и они идут на хитрость: наследуют все свои проверяемые исключения от одного предка — ApplicationNameException. Теперь они обязаны ловить в обработчике еще и его (checked же!):

catch(ValidationException e){
    return new Response(400, e.getMessage());
}
catch(AccessDeniedException e){
    return new Response(403, e.getMessage());
}
catch(ResourceNotFoundException e){
    return new Response(404, e.getMessage());
}
catch(ApplicationNameException e){
    // todo
}

Что делать в последнем catch? Выше мы уже обработали все штатные ситуации, которые предусмотрели, но здесь ApplicationNameException для нас значит не больше, чем Exception: «какая-то непонятная ошибка». Так и обрабатываем:

catch(ValidationException e){
    return new Response(400, e.getMessage());
}
catch(AccessDeniedException e){
    return new Response(403, e.getMessage());
}
catch(ResourceNotFoundException e){
    return new Response(404, e.getMessage());
}
catch(ApplicationNameException e){
    LOGGER.error("Unknown error", e.getMessage());
    return new Response(500, "Oops");
}

А теперь самое интересное: один из методов начинает выбрасывать новый тип исключений, которые должны быть обработаны, а мы забыли добавить соответствующий catch. Если все наши исключения непроверяемые, новое будет обработано как NPE. «Ага!» — злорадно потирают руки адепты checked exceptions. Но постойте, ведь у вас произойдет то же самое: вы отнаследуете новый тип от ApplicationNameException и всё скомпилируется, но вы так же можете забыть добавить специальный обработчик.

Вот так и получается: либо километровый список throws, либо потеря гарантии проверяемости. Оно вам надо?

Проверяемые исключения часто приводят к использованию антипаттернов


Перечислю те, что видел в реальных проектах (названия изменены, примеры упрощены).

throws Exception


Надоели длинные списки throws? Бросай везде просто Exception! Компилятор схавает:

public void doSome() throws Exception{
// do some
}

Какую информацию несет «throws Exception»? Что-то сломалось. Чем это лучше RuntimeException?

Поймал — молчи


try{
// some code
} catch(IOException e){
}

Экономим на stack traces


try{
// some code
} catch(IOException e){
   throw new MyAppException("Error");
}

Контроль документирования


Говорите, компилятор заставляет документировать проверяемые исключения? Просто объявите «throws Exception» — он от вас отвяжется.

Может, дело в самих разработчиках?


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

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

Если какая-либо ошибка является частью нормальной работы программы, вы обязаны ее протестировать. Но зачем в таком случае проверяемые исключения?

Мысль о том, что механизм checked exceptions сделает ваш код более надежным — это надежда на то, что компилятор избавит вас от необходимости продумывать логику работы программы и тестировать ее.

Выводы


Обрабатывайте только те исключения, которые действительно нужно обработать. Указывайте их в сигнатурах методов. Это можно делать и с unchecked exceptions, серьезно.

Подумайте, действительно ли необходимы проверяемые исключения в вашем проекте или от них больше вреда, чем пользы. Относитесь критически ко всему, в том числе — к рекомендациям создателей Java. У вас свой проект, свои требования и особенности. Его делаете вы, а не Джеймс Гослинг, и он вам не поможет. Решать вам.

Благодарности


Огромное спасибо создателям Java за то, что не заставляете нас оборачивать каждое разыменование ссылки:

String s = "hello";
if(s != null){
    System.out.println(s.length());
}

Напоследок


Сильная типизация против сильного тестирования.




К сожалению, не доступен сервер mySQL