Записки тверезого практика - техніка - try
try / catch / finally і виключення
Думаю, навряд чи знайдеться Java-розробник, який хоча б не чув про винятки. В принципі, вони не є винятковою властивістю Java, є вони і в Delphi, і в C ++. Однак в Java виключення використовуються особливо широко. І найчастіше з ними пов'язано чимало помилок. Основна мета цієї статті - систематизація інформації про винятки, їх обробці тощо А саме:
Виняток як явище
Що таке взагалі виняток? Це сигнал про нестандартну - винятковою - ситуації. Ситуації можуть бути самі різні - очікувані чи ні, різного ступеня критичності. І ставитися до цих ситуацій, природно, доводиться по-різному.
Як і все в Java, виключення теж представлені у вигляді класів. Коренем ієрархії служить клас java.lang.Throwable. дослівно - "киданий". Його прямими спадкоємцями є java.lang.Exception і java.lang.Error. від яких і успадковані всі інші винятки. І від яких рекомендується наслідувати власні.
Хочу підкреслити, що виключення не є щось надзвичайне. Це нормальний механізм. Тому не варто намагатися уникати їх використання за всяку ціну. І тим більше не варто намагатися уникати винятків в конструкторах, до чого схильні розробники, добре знайомі з С ++. Зважаючи на наявність збирача сміття витоків пам'яті в цьому випадку не буде. Хіба що дуже постаратися. я маю на увазі навмисно.
Тепер розглянемо типи винятків ближче.
Класифікація винятків
Як я вже згадував, є два "базових" типу винятків - java.lang.Exception і java.lang.Error. Я б сказав, що розрізняються ступенем критичності. Не, зрозуміло, обидва типи можуть бути перехоплені, бо перехоплюється виняток починаючи з рівня java.lang.Throwable. Це поділ швидше логічне. Якщо сталося щось серйозне - не найден метод, який треба викликати, закінчилася пам'ять, переповнення стека, в загальному, щось, після чого відновити нормальну роботу вже навряд чи реально - це помилка, java.lang.Error. Якщо продовження роботи теоретично можливо - це виняток, java.lang.Exception.
Крім уже згаданих двох типів існує також ще один клас, який є суттєвим - java.lang.RuntimeException. Він успадкований від java.lang.Exception і є коренем для всіх винятків часу виконання - тобто що виникають при роботі віртуальної машини. Нульові посилання, розподіл на нуль, невірне значення аргументу, вихід за межі масиви і т.п. - все це виняткові ситуації часу виконання. Чим вони відрізняються від звичайних, ми розглянемо нижче.
Оскільки виключення - точно такі ж класи, в них можна включати і власні змінні, і навіть якусь логіку. Захоплюватися, правда, не варто. Справа в тому, що виключення - клас все-таки виділений. При створенні Throwable існують великі накладні витрати - заповнення стека виклику. Це досить тривала процедура. І тому створювати виключення просто так - собі дорожче. Його потрібно створювати тільки тоді, коли без нього не обійтися, коли воно точно буде викинуто. Простіше це робити відразу разом з директивою throw.
А ось тримати в класі виключення якусь інформацію, яка дозволить його обробити - цілком допустимо. Передавати цю інформацію краще через конструктор.
Отже, приступимо до більш докладного розгляду. Перший базовий тип -
java.lang.Exception
Винятки цього типу я б охарактеріровал так - вони виникають в ситуаціях, які нам непідконтрольні. Скажімо, десеріалізуем ми клас, а дані в невірному форматі. І ми з цим нічого не можемо зробити, крім як адекватно відреагувати.
Будь-яке виключення типу java.lang.Exception має бути оброблено. Це закон. За його виконанням стежить компілятор. Якщо виняток оброблено в методі - воно повинно бути задекларовано в його сигнатурі і в цьому випадку оброблено вище. Або ще вище. Але оброблено воно буде. Єдиний виняток - це java.lang.RuntimeException. про який ми поговоримо далі.
Метод може декларувати будь колічестко винятків. Ні, будь-яке - це, напевно, занадто сильно сказано. Якщо мені не зраджує пам'ять, кількість винятків обмежена 64K - 65536 штук. Для мене це вже означає, що я можу декларувати стільки винятків, скільки мені заманеться.
java.lang.RuntimeException
Оскільки очікувати помилок - нерозумно, виключення типу java.lang.RuntimeException не декларуються. Компілятор це допускає, відповідно, дозволяючи їх не обробляти. Більш того, я б сказав, що ловити java.lang.RuntimeException - поганий тон, бо замість того, щоб усувати причину, ми нейстралізуем наслідки. І найгірше те, що помилки розробника усунути під час виконання практично нереально, фактично, вони за ступенем критичності наближені до java.lang.Error.
Перейдемо тепер до останнього типу -
java.lang.Error
Як я вже згадував, це критичні помилки, після яких відновити нормальну роботу практично нереально. Справді - ну що зробиш при переповненні стека? Або якщо не знайдений метод? Або якщо викликаний абстрактний метод? Або в разі помилки в байт-коді класу? Абсолютно нічого. По крайней мере, без серйозного втручання людини, який може замінити бібліотеку, наприклад. Або поміняти classpath.
У деяких випадках ситуація не настільки критична. Скажімо, брак пам'яті, що викликає java.lang.OutOfMemoryError. Якщо ця помилка сталася в момент виділення великого обсягу пам'яті - наприклад, при створенні масиву, - її можна перехопити і спробувати виділити пам'ять в менших обсягах, змінивши якимось чином алгоритм, який буде цю пам'ять використовувати.
Або, скажімо, в разі, коли JVM підтримує системну кодування - при спробі створити InputStreamReader без вказівки кодування буде викинута помилка. Однак чи потрібна нам системне кодування і наскільки для нас критично її відсутність - вирішувати нам.
Як і у випадку з java.lang.RuntimeException компілятор дозволяє не декларувати і не обробляти помилки з ієрархії java.lang.Error. Перехоплювати їх чи ні - вирішувати вам. Залежить від того, чи можете ви запропонувати прийнятний алгоритм для відновлення.
З класифікацією закінчили, переходимо до наступного питання.
ініціація винятків
А питання таке - що і коли кидати.
Уявімо собі, що у вас створилася виняткова ситуація і працювати код далі не може. Який саме тип виключення використовувати?
Тут саме час скористатися характеристиками винятків, наведеними вище. Якщо виникла виняткова ситуація така, що подальша робота неможлива в принципі - можна кинути виключення з ієрархії java.lang.Error. Хоча у мене особисто таких ситуацій не було в практиці. Якщо виняткова ситуація виникла з вини зовнішніх обставин або користувача програми - не ті дані дали, наприклад, - то це випадок, коли помилки повинні бути оброблені, отже, найкраще використовувати java.lang.Exception. Якщо ж ситуація виникла з вини розробника - наприклад, передали вам в якості параметра null. при тому що в javadoc ви англійським по білому написали, що цього робити не можна - тоді це java.lang.RuntimeException.
Пара слів щодо наведеного прикладу - null як параметр. Теоретично в цій ситуації можна кинути два винятки - NullPointerException і IllegalArgumentException. Нескотря на гадану очевидність вибору я особисто віддаю перевагу другому варіанту. На мій погляд NullPointerException повинна кидати виключно віртуальна машина. Якщо ж я сам перевірив значення і переконався, що воно дорівнює null. а цього бути не повинно - набагато більш інформативно використовувати IllegalArgumentException. якщо це значення аргументу, або ж IllegalStateException - якщо це значення члена класу.
Ну ладно, що кинути саме - це ми як-небудь визначимо. Але іноді виникає якесь ірраціональне бажання не тільки кинути виняток, а й якось його конкретизувати, вказавши причину. Не, можна, звичайно, і по виключенню на кожну помилку створити, але зійти з розуму набагато простіше і швидше.
Перший варіант, який бачиться - текстове повідомлення. Практично у будь-якого класу винятку є конструктор, який приймає як параметр текст. Саме цей текст фігурує в консолі при друку повідомлення про исключени, його ж можна отримати через getMessage (). Це позлезно, коли помилку виявляємо ми самі. А що робити, коли помилка для нас теж "зовнішня", скажімо - ми зловили виняток, кинуте глибше?
В цьому випадку може виручити механізм т.зв. exception chaining - зв'язування винятків. Практично у кожного класу винятку є конструктор, який приймає як параметр Throwable - причину виняткової ситуації. Якщо ж такого конструктора немає - у все того ж Throwable. від якого успадковані все виключення, є метод initCause (Throwable). який можна викликати рівно один раз. І передати йому виняток, що стало причиною того, що було ініційовано наступне виняток.
Навіщо це потрібно. Справа в тому, що добре спроектований код - він як чорний ящик. У нього є свої інтерфейси, певна поведінка, набір виняткових ситуацій, нарешті. А що відбувається всередині - це не грає ролі для того, що знаходиться зовні. Більш того - іноді може зіграти і зворотний ефект. Причин для помилки може бути добрий десяток, і ловити їх все окремо і так само обробляти. Найчастіше просто не потрібно. Саме тому простіше визначити, наприклад, своє виключення (ну або використовувати наявне, не має значення) і кинути саме його, вказавши як причину те, що ми зловили. І вовки ситі, і користувачам коду роботи менше.
Ну ось, від ініціації винятків ми плавно переходимо до їх обробці.
Обробка винятків
Здавалося б, чого простіше? Написав catch (Throwable th) - і все. Однак саме від цього я їх хочу застерегти.
По-перше, як я вже говорив вище. ловити виключення часу виконання - поганий тон. Вони свідчать про помилки. Ловити java.lang.Error або похідні варто тільки якщо ви точно знаєте, що робите. Відновлення після таких помилок не завжди можливо і майже завжди нетривіально.
По-друге - як саме обробляти? Я про це докладно писав у статті про якість коду, розділ Обробка винятків. але повторюся. По кожному Пойманов виключенню необхідно приймати рішення. Просто проковтнути його або вивести в консоль - найнеприємніше, що можна придумати. А цим грішать, на жаль, багато. Людина Новомосковскет XML з файлу, отримує виняток, ковтає його - і намагається далі працювати з цим XML так, як ніби нічого не сталося. Природно отримує NullPointerException. оскільки клас, який він використовує, чи не ініціалізувати. Помилка розробника, хоча і не настільки очевидна.
Чому я говорив про те, що варто заводити свої типи винятків - так ви простіше зможете їх виділити на стадії обробки. Уявіть собі, що існують п'ять причин, за якими може бути викинуто виключення, і в усіх п'яти випадках кидається java.lang.Exception. Ви ж сплять розбиратися, чому саме це виняток викликано. А якщо це буде п'ять різних типів - тут вже простіше простого. На кожен - свій блок catch.
І третє - що робити з ієрархіями винятків. Нехай у вас метод може викинути як IOException. так і Exception. Так ось, зовсім не байдуже, в якому порядку будуть стояти блоки catch. оскільки вони обробляються рівно в тій послідовності, як оголошені. І якщо першим буде catch (Exception ex) - до другого (catch (IOException ioex)) управління просто не дійде. Компілятор про це, звичайно, попередить, більш того - це вважається помилкою. Проте про це варто пам'ятати.
Наступне, чого б я хотів торкнутися -
Перехоплення винятків, що викликали завершення потоку
При використанні декількох потоків бувають ситуації, коли треба знати, як потік завершився. У сенсі - якщо це сталося через виключення, то через якого саме. Для цієї мети починаючи з версії Java 5 існує спеціальний інтерфейс - Thread.UncaughtExceptionHandler. Його реалізацію можна встановити потрібного потоку за допомогою методу setUncaughtExceptionHandler. Можна також встановити обробник за умовчанням за допомогою статичного методу Thread.setDefaultUncaughtExceptionHandler.
Інтерфейс Thread.UncaughtExceptionHandler має один єдиний метод - uncaughtException (Thread t, Throwable e) - в який передається примірник потоку, що завершився винятком, і екземпляр самого винятку. Відновити роботу потоку, природно, вже не вдасться, але зафіксувати факт його ненормального завершення таким чином можна.
Наступне, про що я хотів би поговорити - сама конструкція try / catch / finally.
Конструкція try / catch / finally - тонкощі
З блоком try. думаю, питань не виникає. З блоком catch - сподіваюся, вже теж немає. Залишається блок finally. Що це, і з чим його їдять?
Блок finally йде в самому кінці конструкції try / catch / finally. Ключова його особливість в тому, що він виконується завжди. незалежно від того, спрацював catch чи ні.
Навіщо це потрібно. Подивіться ось на цей фрагмент коду:
Здавалося б все в порядку. А що буде, якщо, наприклад, при виклику rs.first () відбудеться виняток? Так, воно буде опрацьовано. Однак ні ResultSet. ні Statement. ні Connection закриті не будуть. Наслідки дуже сумні - вичерпання відкритих курсорів, з'єднань в пулі і т.п.
Можна, звичайно, помістити цей же код і в блок catch. Однак дублювати код - не найкраща ідея. Правильніше винести код закриття в блок finally. в цьому випадку він буде виконаний як в разі виникнення виключення, так і в разі, коли цього не відбувається.
Розглянемо ще один приклад:
Питання на засипку - яким буде результат? Виконуємо тест і отримуємо:
Тобто в обох випадках результат буде однаковий. причому не той, якого можна було б очікувати! Чому так відбувається? У першому випадку - з блоку try повинно повернутися значення 6 - довжина переданої рядка. Однак виконується блок finally. в якому повертається 0. У результаті вихідне значення втрачається. У другому випадку - спроба викликати toString () при переданому значенні null призведе до виключення - NullPointerException. Це виняток буде перехоплено, тому що є спадкоємцем Exception. З блоку catch має повернутися значення -1, однак виконується блок finally і повертається знову-таки 0, а -1 - втрачається!
Точно так же блок finally може викликати втрату винятків. Подивіться ось на цей приклад:
Цей тест дуже часто давався на інтерв'ю. І правильно відповідали одиниці. Тим часом - результатом його виконання буде висновок в консоль b. І тільки. Після ініціації першого виключення - new Exception ( "a") - буде виконаний блок finally. в якому буде кинуто виключення new IOException ( "b"). І саме це виняток буде спіймано і оброблено. Початкове ж виняток втрачається.
Хотілося б торкнутися ще один момент. В яких комбінаціях можуть існувати блоки try. catch і finally. Зрозуміло, що try - обов'язковий. Він може бути спільно з catch. або з catch і finally одночасно. Окремо try не використовується. А чи є ще варіанти?
Є. try може бути в парі з finally. без catch. Працює це точно так же - після виходу з блоку try виконується блок finally. Це може бути корисно, наприклад, у такій ситуації. При виході з методу вам треба зробити якесь дію. А return в цьому методі варто в кількох місцях. Писати однаковий код перед кожним return недоцільно. Набагато простіше і ефективніше помістити основний код в try. а код, що виконується при виході - в finally.
І остання тема -
відсутність транзакційності
На самому початку я згадував про те, що організувати витік пам'яті при використанні виключень в конструкторах складно. Насправді - можна. Дивимося на приклад.
Приклад цей штучний, але лише до певної міри. Він говорить про дуже важливу особливість винятків:
Властивості транзакційності виключення не володіють - дії, вироблені в блоці try до виникнення виняткових випадках, не скасовуються поcле його виникнення.
І це необхідно враховувати. Стосовно до наведеного прикладу - варто все операції, що можуть призвести до виключення, виконувати якомога раніше. Наприклад, перевіряти передані параметри відразу ж. У деяких випадках для скасування вже вироблених дій може стати в нагоді блок finally. А якщо дуже треба десь зберегти посилання на створюваний об'єкт, причому зробити це необхідно в конструкторі - найкраще залишити це наостанок, коли весь критичний код вже виконано.
Ну от і все. Думаю, бо більша частина цього матеріалу вам була знайома. У всякому разі, сподіваюся на це. Моєю метою було просто упорядкувати інформацію для тих, хто з винятками поки ще знаком поверхнево. Всім дякую!