Думаю, тебе уже доводилось сталкиваться с ситуацией, когда запускаешь код, а в результате получаешь что-то вроде NullPointerException, ClassCastException или что похуже. Потом долгая отладка, разбор, гугление и так далее. Сами по себе исключения — отличная штука: они указывают, где возникла проблема и какого рода. Если хочешь освежить память, да и просто узнать подробнее, загляни в статью Исключения: checked, unchecked и свои собственные (javarush.ru)

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

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

Создаем исключение:


public class PhoneNumberIsExistException extends Exception {

   public PhoneNumberIsExistException(String message) {
       super(message);
   }
}
    

Следом используем его для проверки:


public class PhoneNumberRegisterService {
   List<String> registeredPhoneNumbers = Arrays.asList("+1-111-111-11-11", "+1-111-111-11-12", "+1-111-111-11-13", "+1-111-111-11-14");

   public void validatePhone(String phoneNumber) throws PhoneNumberIsExistException {
       if (registeredPhoneNumbers.contains(phoneNumber)) {
           throw new PhoneNumberIsExistException("Указанный номер телефона уже используется другим клиентом!");
       }
   }
}
    

Для упрощения задачи мы “захардкодим” несколько номеров телефонов — пусть это будет наша база данных. Ну и, наконец, попробуем применить наше исключение:


public class CreditCardIssue {
   public static void main(String[] args) {
       PhoneNumberRegisterService service = new PhoneNumberRegisterService();
       try {
           service.validatePhone("+1-111-111-11-14");
       } catch (PhoneNumberIsExistException e) {
           // здесь можно сделать запись в логи или вывод стека вызовов
		e.printStackTrace();
       }
   }
}
    

Ну что, пора нажать Shift+F10 (если используешь IDEA), то есть, запустить проект. И вот что ты увидишь в консоли:

exception.CreditCardIssue
exception.PhoneNumberIsExistException: Указанный номер телефона уже используется другим клиентом!
at exception.PhoneNumberRegisterService.validatePhone(PhoneNumberRegisterService.java:11)

Ну вот, ты создал свое исключение и даже немного его протестировал. Поздравляю тебя с этим достижением! Рекомендую немного поэкспериментировать с ним, чтобы лучше разобраться, как это работает.

Добавь еще одно: например, для проверки наличия букв. Как ты наверняка знаешь, в США часто используют буквы для упрощения запоминания номера, вроде 1-800-MY-APPLE. То есть, тебе надо проверить, чтобы в номере были только цифры.

Итак, мы создали проверяемое, то есть, checked, исключение. И все бы хорошо, но…

Сообщество программистов разделилось на два лагеря — те, кто за проверяемые исключения и те, кто против. Обе стороны приводят веские аргументы. Среди тех и других есть разработчики экстра-класса: Брюс Эккель критикует концепцию проверяемых исключений, Джеймс Гослинг защищает. Похоже, этот вопрос никогда не будет окончательно закрыт. Тем не менее, давайте рассмотрим основные минусы использования проверяемых исключений.

Главный минус проверяемых исключений — их надо обрабатывать. И тут у нас два варианта: либо обрабатывать на месте, используя try-catch, либо, если у нас одно и то же исключение используется во многих местах, пробрасывать с помощью throws выше, и в классах верхнего уровня их обрабатывать.

Также у нас может возникнуть “простыня” кода, или как иногда можно услышать — boilerplate, то есть много кода, который места занимает много, но смысловой нагрузки несет мало.

Проблемы начинаются в достаточно больших приложениях: обрабатываемых исключений становится много, и метод верхнего слоя легко может обрасти списком throws с десятком исключений.

public OurCoolClass() throws FirstException, SecondException, ThirdException, ApplicationNameException...

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


catch(FirstException e){
    // todo
}
catch(SecondException e){
    // todo
}
catch(ThirdException e){
    // todo
}
catch(ApplicationNameException e){
    // todo
}
    

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


catch(ApplicationNameException e){
    LOGGER.error("Unknown error", e.getMessage());
}
    

И в итоге мы не знаем, что произошло.

Но, казалось бы, можно же все прокинуть одним движением руки:


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

Да, можно. Но какую информацию несет “throws Exception”? Что-то сломалось. Придется проверять все от и до, и ты надолго подружишься с дебагером, чтобы понять причину.

Еще ты можешь встретить конструкцию, которую иногда называют “поймал — молчи”:


try{
// some code
} catch(Exception e){
   throw new ApplicationNameException("Error");
}
    

Тут не нужно лишних слов — по коду все понятно, а точнее — ничего не понятно.

Конечно, ты можешь возразить, что такое не встретишь в реальном коде. Хорошо, давай посмотрим такую штуку, как класс URL из пакета java.net и заглянем в ее недра, в ее код. Follow me if you want to know!

Вот один из конструкторов URL:


public URL(String spec) throws MalformedURLException {
   this(null, spec);
}
    

Как видишь, тут есть интересное проверяемое исключение MalformedURLException. И оно может быть выброшено в случае, цитирую:
if no protocol is specified, or an unknown protocol is found, or spec is null, or the parsed URL fails to comply with the specific syntax of the associated protocol.

То есть:

  1. Если протокол не указан.
  2. Найден неизвестный протокол.
  3. Спецификация имеет значение null.
  4. URL-адрес не соответствует конкретному синтаксису связанного протокола.

Давай создадим метод, который будет создавать объект класса URL:


public URL createURL() {
   URL url = new URL("https://javarush.com");
   return url;
}
    

Как только ты напишешь эти строки в IDE (я пишу в IDEA, но даже в Eclipse и NetBeans это сработает), увидишь вот это:

Это говорит о том, что нам надо либо пробросить исключение, либо “обернуть” в try-catch. Предлагаю пока выбрать второй вариант, чтобы наглядно увидеть, что получится:


public static URL createURL() {
   URL url = null;
   try {
       url = new URL("https://javarush.com");
   } catch(MalformedURLException e){
  e.printStackTrace();
   }
   return url;
}
    

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

Мы можем создать такое исключение, расширив RuntimeException в Java.

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

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

Итак, давай писать код:


public class OurCoolUncheckedException extends RuntimeException {
   public OurCoolUncheckedException(String message) {
       super(message);
   }

   public OurCoolUncheckedException(Throwable cause) {
       super(cause);
   }
  
   public OurCoolUncheckedException(String message, Throwable throwable) {
       super(message, throwable);
   }
}
    

Обрати внимание: мы сделали несколько конструкторов для разных целей. Это позволяет нам расширить возможности нашего исключения. Вот, к примеру, мы можем сделать так, чтобы исключение могло выдать нам код ошибки. Для начала сформируем enum, в котором, собственно, наши коды ошибок и будут лежать:


public enum ErrorCodes {
   FIRST_ERROR(1),
   SECOND_ERROR(2),
   THIRD_ERROR(3);

   private int code;

   ErrorCodes(int code) {
       this.code = code;
   }

   public int getCode() {
       return code;
   }
}
    

А теперь добавим еще один конструктор в класс нашего исключения:


public OurCoolUncheckedException(String message, Throwable cause, ErrorCodes errorCode) {
   super(message, cause);
   this.errorCode = errorCode.getCode();
}
    

И да, не забудем добавить поле, чуть было не забыли:


private Integer errorCode;
    

Ну и конечно, метод для получения этого кода:


public Integer getErrorCode() {
   return errorCode;
}
    

Посмотрим на весь класс целиком, чтобы можно было проверить и сравнить:

public class OurCoolUncheckedException extends RuntimeException {
   private Integer errorCode;

   public OurCoolUncheckedException(String message) {
       super(message);
   }

   public OurCoolUncheckedException(Throwable cause) {
       super(cause);
   }

   public OurCoolUncheckedException(String message, Throwable throwable) {

       super(message, throwable);
   }

   public OurCoolUncheckedException(String message, Throwable cause, ErrorCodes errorCode) {
       super(message, cause);
       this.errorCode = errorCode.getCode();
   }
   public Integer getErrorCode() {
       return errorCode;
   }
}
    

Вот наше исключение и готово! Как видишь, ничего особо сложного нет. Проверим его в работе:


   public static void main(String[] args) {
       getException();
   }
   public static void getException(){
       throw new OurCoolUncheckedException("Наше крутое исключение!");
   }
    

Запустим наше небольшое приложение и увидим в консоли примерно следующее:

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


public static void main(String[] args) throws Exception {

   OurCoolUncheckedException exception = getException(3);
   System.out.println("getException().getErrorCode() = " + exception.getErrorCode());
   throw exception;

}

public static OurCoolUncheckedException getException(int errorCode){
   return switch (errorCode) {
   case 1:
       return new OurCoolUncheckedException("Наше крутое исключение! Мы получили ошибку: " + ErrorCodes.FIRST_ERROR.getCode(), new Throwable(), ErrorCodes.FIRST_ERROR);
   case 2:
       return new OurCoolUncheckedException("Наше крутое исключение! Мы получили ошибку: " + ErrorCodes.SECOND_ERROR.getCode(), new Throwable(), ErrorCodes.SECOND_ERROR);
   default: // здесь мы подхватим тройку и все остальные коды, которые мы еще не добавили, то есть, это действие по умолчанию. Подробнее можешь узнать здесь Switch case Java (оператор switch в Java) (javarush.ru)
       return new OurCoolUncheckedException("Наше крутое исключение! Мы получили ошибку: " + ErrorCodes.THIRD_ERROR.getCode(), new Throwable(), ErrorCodes.THIRD_ERROR);
}

}
    

С исключениями можно работать как с объектами, хотя, уверен, ты и так знаешь, что в Java все есть объект.

И смотри, что мы сделали. Сначала мы изменили метод, который теперь не бросает, а просто создает исключение в зависимости от того, какой нам прилетел параметр. Далее при помощи switch-case генерируем исключение с нужным нам кодом ошибки и сообщением. А в основном методе мы созданное исключение получили, достали код ошибки и бросили его.

Давай запустим и посмотрим, что попадет в консоль:

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

Ну как? Надеюсь, у тебя все получилось!

Вообще, тема исключений довольно обширна и не так однозначна. Тут будет еще много споров и будет сломано немало копий. К примеру, только в Java есть проверяемые исключения. Из наиболее распространенных языков я не видел ни один, который бы их использовал.

Очень хорошо про исключения в целом написал Брюс Эккель в своей книге “Философия Java”, в главе 12, рекомендую ознакомиться! Также загляни в первый том “Java. Библиотека профессионала” Хорстманна, в главу 7 — там тоже много интересного.

Небольшие итоги

  1. Пиши все в лог! Логируй сообщения, которые может выдать исключение. В большинстве случаев это очень поможет в отладке и позволит понять, что произошло. Не оставляй пустым блок catch, иначе он будет просто “съедать” исключение, и у тебя не будет данных для поиска проблем.

  2. Плохая практика в отношении исключения — поймать их все одним разом (как сказал один мой коллега, это не покемон — это Java), Поэтому избегай catch(Exception e) или, того хуже, catch(Throwable t).

  3. Бросай исключение как можно раньше. Это хорошая практика программирования на Java. Когда ты будешь изучать фреймворки, типа Spring, увидишь, что он работает по принципу Failed First. То есть, “упасть” как можно раньше, чтобы быстрее найти ошибку. Это, конечно, несет определенные неудобства. Тем не менее, такой подход помогает создавать более надежный код.

  4. При вызове других частей кода лучше всего ловить определенные исключения. Если вызываемый код генерирует несколько исключений, плохая практика программирования — перехватывать только родительский класс этих исключений. Например, если вызываемый код выдает FileNotFoundException с участием IOException. И в твой код, который вызывает этот модуль, лучше написать два блока catch для захвата каждого из исключений, вместо одного catch для перехвата Exception.

  5. Лови исключения только тогда, когда сможешь эффективно для пользователя и отладки их обработать.

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

  7. Когда ты создаешь свои классы исключений, следи за именованием! Ты, скорее всего, уже знаешь, что правильное именование классов, переменных, методов и пакетов — это крайне важно. Исключения — не исключения (прости за тавтологию)! В конце всегда ставь слово Exception, а название исключения должно четко говорить об ошибке, которую оно ловит. Пример — FileNotFoundException.

  8. Документируй исключения. Желательно писать javadoc @throws для исключений. Это будет особенно полезно в тех случаях, когда твои разработки предоставляют какие-либо интерфейсы. Да и самому будет потом проще разобраться в своем же коде. Вот как ты думаешь, откуда можно узнать, что делает MalformedURLException? Из javadoc! Да, перспектива писать документацию не сильно радует, но, поверь, ты себе скажешь спасибо, когда спустя полгода вернешься к своему же коду.

  9. Освобождай ресурсы и не пренебрегай конструкцией try-with-resources. Если ты еще не знаешь, что это такое, то ознакомься с ними здесь — Java 7 try-with-resources.

  10. Это, скорее, общий итог: используй исключения с умом. Бросить исключение — это достаточно “дорогая” по ресурсам операция. Не исключено, что во многих случаях будем проще не бросать исключений, а вернуть, скажем, логическую переменную, которая укажет, как прошла операция, используя простой и более “дешевый” if-else.

    Также может возникнуть соблазн завязать на исключения логику приложения, чего делать явно не стоит. Исключения, как мы уже говорили в начале статьи, это исключительная ситуация, а не штатная, и для их “профилактики” есть различные инструменты. В частности, есть Optional, чтобы предотвратить NullPointerException, или Scanner.hasNext и ему подобные для недопущения исключения IOException, которое может бросить метод read().