Лабораторна робота №4. Обробка виключень
Хід роботи
При виконанні програми можуть виникати помилки. В одних випадках це викликано помилками програміста, в інших - зовнішніми причинами. Наприклад, може виникнути помилка вводу/виводу при роботі з файлом або мережним з'єднанням. У класичних мовах програмування, наприклад, було потрібно перевіряти деяку умову, що вказувало на наявність помилки, і залежно від цього вживати ті або інші дії.
Наприклад:
...
int statusCode = someAction();
if (statusCode){
... обробка помилки
} else {
statusCode = anotherAction();
if(statusCode) {
... обробка помилки ...
}
}
...
В Java з'явилося більш просте й елегантне рішення - обробка виняткових ситуацій.
try{
someAction();
anotherAction();
} catch(Exception e) {
// обробка виняткової ситуації
}
Легко помітити, що такий підхід є не тільки витонченим, але й більше надійним і простим для розуміння.
Причини виникнення помилок
Існує три причини виникнення виняткових ситуацій.
Спроба виконати некоректний вираз. Наприклад, ділення на нуль, або звертання до об'єкта по посиланню, рівної null, спроба використати клас, опис якого (class-файл) відсутній, і т.д. У таких випадках завжди можна точно вказати, у якім місці відбулася помилка, - саме в некоректному вираженні.
Виконання оператора throw Цей оператор застосовується для явного породження помилки. Очевидно, що й тут можна вказати місце виникнення виняткової ситуації.
Асинхронні помилки під час виконання програми.
Причиною таких помилок можуть бути збої усередині самої віртуальної машини (адже вона також є програмою), або виклик методу stop() у потоку виконання Thread.
У цьому випадку неможливо вказати точне місце програми, де відбувається виняткова ситуація. Якщо ми спробуємо зупинити потік виконання (викликавши метод stop()), нам не вдасться завбачати, при виконанні якого саме виразу цей потік зупиниться.
Таким чином, всі помилки в Java діляться на синхронні й асинхронні. З першими порівняно простіше працювати, тому що принципово можливо знайти точне місце в коді, що є причиною виникнення виняткової ситуації. Звичайно, Java є строгою мовою в тому розумінні, що всі вираження до крапки збою обов'язково будуть виконані, і в той же час жодне наступне вираження ніколи виконане не буде. Важливо пам'ятати, що помилки можуть виникати як через недостатню уважність програміста (відсутній потрібний клас, або індекс масиву вийшов за припустимі границі), так і по незалежним від нього причинам (відбувся розрив мережного з'єднання, збій апаратного забезпечення, наприклад, жорсткого диска й ін.).
Асинхронні помилки набагато складніше у виявленні й виправленні. Звичайному розробнику дуже важко виявити причини збоїв у віртуальній машині. Це можуть бути помилки творців JVM, несумісність із операційною системою, апаратний збій і багато чого іншого. Все-таки сучасні віртуальні машини реалізовані досить добре й подібні збої відбуваються вкрай рідко (за умови використання якісних комплектуючих).
Аналогічна ситуація спостерігається й у випадку із примусовою зупинкою потоків виконання. Оскільки ця дія виконується операційною системою, ніколи не можна пророчити, у якому саме місці зупиниться потік. Це означає, що програма може багаторазово відпрацювати коректно, а потім зненацька дати збій просто через те, що потік зупинився в якомусь іншому місці. Із цієї причини примусова зупинка не рекомендується.
При виникненні виняткової ситуації керування передається від коду, що викликав виняткову ситуацію, на найближчий блок catch (або нагору по стеку) і створюється об'єкт, успадкований від класу Throwable, або його нащадків (див. діаграму ієрархії класів-виключень), що містить інформацію про виняткову ситуацію й використається при її обробці. Власне, у блоці catch вказується саме клас оброблюваної ситуації. Докладно обробка помилок розглядається нижче.
Ієрархія, по якій передається інформація про виняткову ситуацію, залежить від того, де ця виняткова ситуація виникла. Якщо це
метод, то керування буде передаватися в те місце, де даний метод був викликаний;
конструктор, то керування буде передаватися туди, де спробували створити об'єкт (як правило, застосовуючи оператор new);
статичний ініціалізатор, те керування буде передано туди, де відбулося перше звертання до класу, що його ініціалізувало.
Допускається створення власних класів виняткових ситуацій. Здійснюється це за допомогою механізму спадкування, тобто клас користувальницької виняткової ситуації повинен бути успадкований від класу Throwable, або його нащадків.
Обробка виняткових ситуацій
Конструкція try-catch
У загальному випадку конструкція виглядає так:
try {
...
} catch(SomeExceptionClass e) {
...
} catch(AnotherExceptionClass e) {
...
}
Працює вона в такий спосіб. Спочатку виконується код, який знаходиться у фігурних дужках оператора try. Якщо під час його виконання не відбувається ніяких позаштатних ситуацій, то далі керування передається за закриваючу фігурну дужку останнього оператора catch, асоційованого з даним оператором try.
Якщо в межах try виникає виняткова ситуація, то далі виконання коду виробляється по одному з перерахованих нижче сценаріїв.
Виникла виняткова ситуація, клас якої зазначений як параметр одного із блоків catch. У цьому випадку провадиться виконання блоку коду, асоційованого з даним catch (ув'язненого у фігурні дужки). Далі, якщо код у цьому блоці завершується нормально, те й весь оператор try завершується нормально й керування передається на оператор (вираження), що випливає за закриваючою фігурною дужкою останнього catch. Якщо код в catch завершується не штатно, то й весь try завершується нештатно по тій же причині.
Якщо виникла виняткова ситуація, клас якої не зазначений як аргумент у жодному catch, те виконання всього try завершується нештатно.
Конструкція try-catch-finally
Оператор finally призначений для того, щоб забезпечити гарантоване виконання якого-небудь фрагмента коду. Поза залежністю від того, чи виникла виняткова ситуація в блоці try, чи заданий підходящий блок catch, чи не виникла помилка в самому блоці catch, - однаково блок finally буде зрештою виконаний.
Послідовність виконання такої конструкції наступна: якщо оператор try виконаний нормально, те буде виконаний блок finally. У свою чергу, якщо оператор finally виконується нормально, то й весь оператор try виконується нормально.
Якщо під час виконання блоку try виникає виключення й існує оператор catch, що перехоплює даний тип виключення, відбувається виконання пов'язаного з catch блоку. Якщо блок catch виконується нормально, або ненормально, однаково потім виконується блок finally. Якщо блок finally завершується нормально, то оператор try завершується так само, як завершився блок catch.
Якщо в списку операторів catch не перебуває такого, котрий обробив би виникле виключення, те однаково виконується блок finally. У цьому випадку, якщо finally завершиться нормально, весь try завершиться ненормально по тій же причині, по якій було порушене виконання try.
У всіх випадках, якщо блок finally завершується ненормально, те весь try завершиться ненормально по тій же причині.
Розглянемо приклад застосування конструкції try-catch-finally.
try {
byte [] buffer = new byte[128];
FileInputStream fis =
new FileInputStream("file.txt");
while(fis.read(buffer) > 0) {
... обробка даних ...
}
} catch(IOException es) {
... обробка виключення ...
} finally {
fis.flush();
fis.close();
}
Якщо в даному прикладі помістити оператори очищення буфера й закриття файлу відразу після закінчення обробки даних, то при виникненні помилки уведення/виводу коректного закриття файлу не відбудеться. Ще раз відзначимо, що блок finally буде виконаний у кожному разі, поза залежністю від того, відбулася обробка чи виключення ні, виникло це чи виключення ні.
В конструкції try-catch-finally обов'язковим є використання однієї із частин оператора catch або finally. Тобто конструкція
try {
...
} finally {
...
}
є цілком припустимою. У цьому випадку блок finally при виникненні виняткової ситуації повинен бути виконаний, хоча сама виняткова ситуація оброблена не буде й буде передана для обробки на більше високий рівень ієрархії.
Якщо обробка виняткової ситуації в коді не передбачена, то при її виникненні виконання методу буде припинена й виняткова ситуація буде передана для обробки коду більше високого рівня. Таким чином, якщо виняткова ситуація відбудеться у викликуваному методі, то керування буде передано зухвалому методу й обробку виняткової ситуації повинен зробити він. Якщо виняткова ситуація виникла в коді найвищого рівня (наприклад, методі main()), то керування буде передано виконуючій системі Java і виконання програми буде припинене (більш точно - буде зупинений потік виконання, у якому відбулася така помилка).
Використання оператора throw
Крім того, що визначена виняткова ситуація може бути порушена виконуючою системою Java, програміст сам може явно породити помилку. Робиться це за допомогою оператора throw.
Наприклад:
...
public int calculate(int theValue) {
if( theValue < 0) {
throw new Exception(
"Параметр для обчислення не повинен
бути відємним");
}
}
...
У цьому випадку передбачається, що як параметр методу може бути передане тільки додатне значення; якщо ця умова не виконана, то за допомогою оператора throw породжується виняткова ситуація. (Для успішної компіляції також потрібно в заголовку методу вказати throws Exception - це вираження розглядається нижче.)
Метод повинен делегувати обробку виняткової ситуації його коду, що викликав. Для цього в сигнатурі методу застосовується ключове слово throws, після якого повинні бути перераховані через кому всі виняткові ситуації, які може викликати даний метод. Тобто наведений вище приклад повинен бути наведений до наступного виду:
...
public int calculate(int theValue)
throws Exception {
if( theValue < 0) {
throw new Exception(
"Some descriptive info");
}
}
...
Таким чином, створення виняткової ситуації в програмі виконується за допомогою оператора throw з аргументом, значення якого може бути приведене до типу Throwable.
У деяких випадках після обробки виняткової ситуації може виникнути необхідність передати інформацію про неї в код, який викликаеться
У цьому випадку помилка з'являється вдруге.
Наприклад:
...
try {
...
} catch(IOException ex) {
...
// Обробка виняткової ситуації
...
// Повторне порушення виняткової
// ситуації
throw ex;
}
Розглянемо ще один випадок.
Припустимо, що оператор throw застосовується усередині конструкції try-catch.
try {
...
throw new IOException();
...
} catch(Exception e) {
...
}
У цьому випадку виключення, порушене в блоці try, не буде передано для обробки на більше високий рівень ієрархії, а обробиться в межах блоку try-catch, тому що тут міститься оператор, що може це виключення перехопити. Тобто відбудеться неявна передача керування на відповідний блок catch.
Перевіряємі й неперевіряємі виключення
Всі виняткові ситуації можна розділити на дві категорії: що перевіряються (checked) і непровіряютсья (unchecked).
Всі виключення, породжувані від Throwable, можна розбити на три групи. Вони визначаються трьома базовими типами: спадкоємцями Throwable - класами Error і Exception, а також спадкоємцем Exception - RuntimeException.
Помилки, породжені від Exception (і не є спадкоємцями RuntimeException), є що перевіряють. Т.е. під час компіляції перевіряється, чи передбачена обробка можливих виняткових ситуацій. Як правило, це помилки, пов'язані з оточенням програми (мережним, файловим вводом-виводом і ін.), які можуть виникнути поза залежністю від того, коректно написаний чи код ні. Наприклад, відкриття мережного з'єднання або файлу може привести до виникнення помилки й компілятор жадає від програміста передбачити якісь дії для обробки можливих проблем. У такий спосіб підвищується надійність програми, її стійкість при можливих збоях.
Виключення, породжені від RuntimeException, є неперевіряємі й компілятор не вимагає обов'язкової їхньої обробки.
Як правило, це помилки програми, які при правильному кодуванні виникати не повинні (наприклад, IndexOutOfBoundsException - вихід за межі масиву, java.lang.ArithmeticException - ділення на нуль). Тому, щоб не загромаджувати програму, компілятор залишає на розсуд програміста обробку таких виключень за допомогою блоків try-catch.
Виключення, породжені від Error, також не перевіряються. Вони призначені для того, щоб повідомити додаток про виникнення фатальної ситуації, що програмним способом усунути практично неможливо (хоча формально оброблювач допускається). Вони можуть свідчити про помилки програми, але, як правило, це непереборні проблеми на рівні JVM. Як приклад можна призвести до StackOverflowError (переповнення стека), OutOfMemoryError (недостача пам'яті).
Если в конструкції обробки виключень використається декілька операторів catch, класи виключень потрібно перераховувати в них послідовно, від менш загального до більше загального. Розглянемо два приклади:
try {
...
}
catch(Exception e) {
...
}
catch(IOException ioe) {
...
}
catch(UserException ue) {
...
}
Рис. 1. Ієрархія класів стандартних виключень.
У даному прикладі при виникненні виняткової ситуації (клас, породжений від Exception) буде виконуватися завжди тільки перший блок catch. Інші не будуть виконані ні при яких умовах. Ця ситуація відслідковується компілятором, що повідомляє про UnreachableCodeException (помилка - недосяжний код). Правильно дана конструкція буде виглядати так:
try {
...
}
catch(UserException ue) {
...
}
catch(IOException ioe) {
...
}
catch(Exception e) {
...
}
У цьому випадку буде виконуватися послідовна обробка виключень. І у випадку, якщо не передбачена обробка того типу виключення, що виникло (наприклад, AnotherUserException), буде виконаний блок catch(Exception e){:}
Якщо спрацьовує один із блоків catch, то інші блоки в даній конструкції try-catch виконуватися не будуть.
Створення користувальницьких класів виключень
Як ми вже відзначали, допускається створення власних класів виключень. Для цього досить створити свій клас, успадкувавши його від будь-якого спадкоємця java.lang.Throwable (або від самого Throwable).
Приклад:
public class UserException extends Exception {
public UserException() {
super();
}
public UserException(String descry) {
super(descry);
}
}
Відповідно, дане виключення буде створюватися в такий спосіб:
throw new UserException(
"Додатковий опис");
Перевизначення методів і виключення
При перевизначенні методів варто пам'ятати, що якщо перевизначений метод повідомляє список можливих виключень, то він не може розширювати цей список, але може його звужувати. Розглянемо приклад:
public class BaseClass{
public void method () throws IOException {
...
}
}
public class LegalOne extends BaseClass {
public void method () throws IOException {
...
}
}
public class LegalTwo extends BaseClass {
public void method () {
...
}
}
public class LegalTree extends BaseClass {
public void method ()
throws
EOFException,MalformedURLException {
...
}
}
public class IllegalOne extends BaseClass {
public void method ()
throws
IOException,IllegalAccessException {
...
}
}
public class IllegalTwo extends BaseClass {
public void method () {
...
throw new Exception();
}
}
У цьому випадку:
визначення класу LegalOne буде коректним, тому що перевизначення методу method() вірне (список помилок не змінився);
визначення класу LegalTwo буде коректним, тому що перевизначення методу method() вірне (новий метод не може викидати помилок, а виходить, не розширює список можливих помилок старого методу);
визначення класу LegalTree буде коректним, тому що перевизначення методу method() буде вірним (новий метод може створювати виключення, які є підкласами виключення, порушуваного в старому методі, тобто список звузився);
пропрерозподіл класу IlegalOne буде некоректним, тому що перевизначення методу method() невірно (IllegalAccessException не є підкласом IOException, список розширився);
визначення класу IlegalTwo буде некоректним: хоча заголовок method() оголошений вірно (список не розширився), у тілі методу кидається виключення, не зазначене в throws.
Особливі випадки
Під час виконання коду можуть виникати ситуації, які майже не описані в літературі.
Розглянемо таку ситуацію:
import java.io.*;
public class Test {
public Test() {
}
public static void main(String[] args) {
Test test = new Test();
try {
test.doFileInput("bogus.file");
}
catch (IOException ex) {
System.out.println("Second exception handle stack trace");
ex.printStackTrace();
}
}
private String doFileInput(String fileName)
throws FileNotFoundException,IOException {
String retStr = "";
java.io.FileInputStream fis = null;
try {
fis = new java.io.FileInputStream(fileName);
}
catch (FileNotFoundException ex) {
System.out.println("First exception handle stack trace");
ex.printStackTrace();
throw ex;
}
return retStr;
}
}
Результат роботи буде виглядати в такий спосіб:
java.io.FileNotFoundException: bogus.file (The system cannot find
the file specified)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:64)
at experiment.Test.doFileInput(Test.java:33)
at experiment.Test.main(Test.java:21)
First exception handle stack trace
java.io.FileNotFoundException: bogus.file (The system cannot find
the file specified)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:64)
at experiment.Test.doFileInput(Test.java:33)
at experiment.Test.main(Test.java:21)
Second exception handle stack trace
Тому що при вторинному порушенні використається той самий об'єкт Exception, стік в обох випадках буде містити ту саму послідовність викликів. Тобто при повторному порушенні виключення, якщо ми використаємо той же об'єкт, зміни його параметрів не відбувається.
Рассмотрим інший приклад:
import java.io.*;
public class Test {
public Test() {
}
public static void main(String[] args) {
Test test = new Test();
try {
test.doFileInput();
}
catch (IOException ex) {
System.out.println("Exception hash code " + ex.hashCode());
ex.printStackTrace();
}
}
private String doFileInput()
throws FileNotFoundException,IOException{
String retStr = "";
java.io.FileInputStream fis = null;
try {
fis = new java.io.FileInputStream("bogus.file");
}
catch (FileNotFoundException ex) {
System.out.println("Exception hash code " + ex.hashCode());
ex.printStackTrace();
fis = new java.io.FileInputStream("anotherBogus.file");
throw ex;
}
return retStr;
}
}
java.io.FileNotFoundException: bogus.file (The system cannot find
the file specified)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:64)
at experiment.Test.doFileInput(Test.java:33)
at experiment.Test.main(Test.java:21)
Exception hash code 3214658
java.io.FileNotFoundException: (The system cannot find the path
specified)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:64)
at experiment.Test.doFileInput(Test.java:38)
at experiment.Test.main(Test.java:21)
Exception hash code 6129586
Нескладно помітити, що, хоча послідовність викликів та сама, у викликуваному й зухвалому методах обробляються різні об'єкти виключень.