10. Лекция: Операторы и структура кода. Исключения
10. Лекция: Операторы и структура кода. Исключения
После ознакомления с типами данных в Java, правилами объявления классов и интерфейсов, а также с массивами, из базовых свойств языка остается рассмотреть лишь управление ходом выполнения программы. В этой лекции вводятся важные понятия, связанные с данной темой, описываются метки, операторы условного перехода, циклы, операторы break и continue и другие. Следующая тема посвящена более концептуальным механизмам Java, а именно работе с ошибками или исключительными ситуациями. Рассматриваются причины возникновения сбоев, способы их обработки, объявление собственных типов исключительных ситуаций. Описывается разделение всех ошибок на проверяемые и непроверяемые компилятором, а также ошибки времени исполнения.
Управление ходом программы
Управление потоком вычислений является фундаментальной основой всего языка программирования. В данной лекции будут рассмотрены основные языковые конструкции и способы их применения.
Синтаксис выражений весьма схож с синтаксисом языка С, что облегчает его понимание для программистов, знакомых с этим языком, и вместе с тем имеется ряд отличий, которые будут рассмотрены позднее и на которые следует обратить внимание.
Порядок выполнения программы определяется операторами. Операторы могут содержать другие операторы или выражения.
Нормальное и прерванное выполнение операторов
Последовательность выполнения операторов может быть непрерывной, а может и прерываться (при возникновении определенных условий). Выполнение оператора может быть прервано, если в потоке вычислений будут обнаружены операторы
break
continue
return
Тогда управление будет передано в другое место (в соответствии с правилами обработки этих операторов, которые мы рассмотрим позже).
Нормальное выполнение оператора может быть прервано также при возникновении исключительных ситуаций, которые тоже будут рассмотрены позднее. Явное возбуждение исключительной ситуации с помощью оператора throw также прерывает нормальное выполнение оператора и передает управление выполнением программы (далее просто управление) в другое место.
Прерывание нормального исполнения всегда вызывается определенной причиной. Приведем список таких причин:
break (без указания метки );
break (с указанием метки );
continue (без указания метки );
continue (с указанием метки );
return (с возвратом значения);
return (без возврата значения);
throw с указанием объекта Throwable, а также все исключения, вызываемые виртуальной машиной Java.
Выражения могут завершаться нормально и преждевременно (аварийно). В данном случае термин "аварийно" вполне применим, т.к. причиной необычной последовательности выполнения выражения может быть только возникновение исключительной ситуации.
Если в операторе содержится выражение, то в случае его аварийного завершения выполнение оператора тоже будет завершено преждевременно (т.е. нормальный ход выполнения оператора будет нарушен).
В том случае, если в операторе имеется вложенный оператор и его завершение происходит ненормально, то так же ненормально завершается оператор, содержащий вложенный (в некоторых случаях это не так, что будет оговариваться особо).
Блоки и локальные переменные
Блок - это последовательность операторов, объявлений локальных классов или локальных переменных, заключенных в скобки. Область видимости локальных переменных и классов ограничена блоком, в котором они определены.
Операторы в блоке выполняются слева направо, сверху вниз. Если все операторы (выражения) в блоке выполняются нормально, то и весь блок выполняется нормально. Если какой-либо оператор (выражение) завершается ненормально, то и весь блок завершается ненормально.
Нельзя объявлять несколько локальных переменных с одинаковыми именами в пределах видимости блока. Приведенный ниже код вызовет ошибку времени компиляции.
public class Test {
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
int x;
lbl: {
int x = 0;
System.out.println("x = " + x);
}
}
}
В то же время не следует забывать, что локальные переменные перекрывают видимость переменных-членов. Так, следующий пример отработает нормально.
public class Test {
static int x = 5;
public Test() { }
public static void main(String[] args) {
Test t = new Test();
int x = 1;
System.out.println("x = " + x);
}
}
На консоль будет выведено x = 1.
То же самое правило применимо к параметрам методов.
public class Test {
static int x;
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
t.test(5);
System.out.println("Member value x = " + x);
}
private void test(int x) {
this.x = x + 5;
System.out.println("Local value x = " + x);
}
}
В результате работы этого примера на консоль будет выведено:
Local value x = 5
Member value x = 10
На следующем примере продемонстрируем, что область видимости локальной переменной ограничена областью видимости блока, или оператора, в пределах которого данная переменная объявлена.
public class Test {
static int x = 5;
public Test() {
}
public static void main(String[] args) {
Test t = new Test(); {
int x = 1;
System.out.println("First block x = " + x);
}
{
int x = 2;
System.out.println("Second block x =" + x);
}
System.out.print("For cycle x = ");
for(int x =0;x<5;x++) {
System.out.print(" " + x);
}
}
}
Данный пример откомпилируется без ошибок и на консоль будет выведен следующий результат:
First block x = 1
Second block x =2
For cycle x = 0 1 2 3 4
Следует помнить, что определение локальной переменной есть исполняемый оператор. Если задана инициализация переменной, то выражение исполняется слева направо и его результат присваивается локальной переменной. Использование неинициализированных локальных переменных запрещено и вызывает ошибку компиляции.
Следующий пример кода
public class Test {
static int x = 5;
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
int x;
int y = 5;
if( y > 3) x = 1;
System.out.println(x);
}
}
вызовет ошибку времени компиляции, т.к. возможны условия, при которых переменная x может быть не инициализирована до ее использования (несмотря на то, что в данном случае оператор if(y > 3) и следующее за ним выражение x = 1; будут выполняться всегда).
Пустой оператор
Точка с запятой (;) является пустым оператором. Данная конструкция вполне применима там, где не предполагается выполнение никаких действий. Преждевременное завершение пустого оператора невозможно.
Метки
Любой оператор, или блок, может иметь метку. Метку можно указывать в качестве параметра для операторов break и continue. Область видимости метки ограничивается оператором, или блоком, к которому она относится. Так, в следующем примере мы получим ошибку компиляции:
public class Test {
static int x = 5;
static {
}
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
int x = 1;
Lbl1: {
if(x == 0) break Lbl1;
}
Lbl2: {
if(x > 0) break Lbl1;
}
}
}
В случае, если имеется несколько вложенных блоков и операторов, допускается обращение из внутренних блоков к меткам, относящимся к внешним.
Этот пример является вполне корректным:
public class Test {
static int x = 5; static {
}
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
int L2 = 0;
Test: for(int i = 0; i< 10;i++) {
test: for(int j = 0; j< 10;j++) {
if( i*j > 50) break Test;
}
}
}
private void test() {
;
}
}
В этом же примере можно увидеть, что метки используют пространство имен, отличное от пространства имен переменных, методов и классов.
Традиционно использование меток не рекомендуется, особенно в объектно-ориентированных языках, поскольку серьезно усложняет понимание порядка выполнения кода, а значит, и его тестирование и отладку. Для Java этот запрет можно считать не столь строгим, поскольку самый опасный оператор goto отсутствует. В некоторых ситуациях (как в рассмотренном примере с вложенными циклами) использование меток вполне оправданно, но, конечно, их применение следует ограничивать лишь самыми необходимыми случаями.
Оператор if
Пожалуй, наиболее распространенной конструкцией в Java, как и в любом другом структурном языке программирования, является оператор условного перехода.
В общем случае конструкция выглядит так:
if (логическое выражение) выражение или блок 1
else выражение или блок 2
Логическое выражение может быть любой языковой конструкцией, которая возвращает булевский результат. Отметим отличие от языка C, в котором в качестве логического выражения могут использоваться различные типы данных, где отличное от нуля выражение трактуется как истинное значение, а ноль - как ложное. В Java возможно использование только логических выражений.
Если логическое выражение принимает значение "истина", то выполняется выражение или блок 1, в противном случае - выражение или блок 2. Вторая часть оператора ( else ) не является обязательной и может быть опущена. Т.е. конструкция if(x == 5) System.out.println("Five") вполне допустима.
Операторы if-else могут каскадироваться.
String test = "smb";
if( test.equals("value1") {
...
} else if (test.equals("value2") {
...
} else if (test.equals("value3") {
...
} else {
...
}
Следует помнить, что оператор else относится к ближайшему к нему оператору if. В данном случае последнее условие else будет выполняться, только если не выполнено предыдущее. Заключительная конструкция else относится к самому последнему условию if и будет выполнена только в том случае, если ни одно из вышеперечисленных условий не будет истинным. Если хотя бы одно из условий выполнено, то все последующие выполняться не будут.
Например:
...
int x = 5;
if( x < 4) {
System.out.println("Меньше 4");
}
else if (x > 4) {
System.out.println("Больше 4");
} else if (x == 5) {
System.out.println("Равно 5");
} else {
System.out.println("Другое значение");
}
Предложение "Равно 5" в данном случае напечатано не будет.
Оператор switch
Оператор switch() удобно использовать в случае необходимости множественного выбора. Выбор осуществляется на основе целочисленного значения.
Структура оператора:
switch(int value) {
case const1:
выражение или блок
case const2:
выражение или блок
case constn:
выражение или блок
default:
выражение или блок
}
Причем, фраза default не является обязательной.
В качестве параметра switch может использоваться переменная типа byte, short, int, char или выражение. Выражение должно в конечном итоге возвращать параметр одного из указанных ранее типов. В операторе switch не могут применяться значения примитивного типа long и ссылочных типов Long, String, Integer, Byte и т.д.
При выполнении оператора switch производится последовательное сравнение значения x с константами, указанными после case, и в случае совпадения выполняется выражение следующее за этим условием. Если выражение выполнено нормально и нет преждевременного его завершения, то производится выполнение для последующих case. Если же выражение, следующее за case, завершилось ненормально, то будет прекращено выполнение всего оператора switch.
Если не выполнен ни один оператор case, то выполнится оператор default, если он имеется в данном switch. Если оператора default нет и ни одно из условий case не выполнено, то ни одно из выражений switch также выполнено не будет.
Следует обратить внимание, что, в отличие от многозвенного if-else, если какое-либо условие case выполнено, то выполнение switch не прекратится, а будут выполняться следующие за ним выражения. Если этого необходимо избежать, то после кода следующего за оператором case используется оператор break, прерывающий дальнейшее выполнение оператора switch.
После оператора case должен следовать литерал, который может быть интерпретирован как 32-битовое целое значение. Здесь не могут применяться выражения и переменные, если они не являются final static.
Рассмотрим пример:
int x = 2;
switch(x) {
case 1:
case 2:
System.out.println("Равно 1 или 2");
break;
case 3:
case 4:
System.out.println("Равно 3 или 4");
break;
default:
System.out.println(
"Значение не определено");
}
В данном случае на консоль будет выведен результат "Равно 1 или 2". Если же убрать операторы break, то будут выведены все три строки.
Вот такая конструкция вызовет ошибку времени компиляции.
int x = 5;
switch(x) {
case y: // только константы!
...
break;
}
В операторе switch не может быть двух case с одинаковыми значениями.
Т.е. конструкция
switch(x) {
case 1:
System.out.println("One");
break;
case 1:
System.out.println("Two");
break;
case 3:
System.out.println("Tree or other value");
}
недопустима.
Также в конструкции switch может быть применен только один оператор default.
Управление циклами
В языке Java имеется три основных конструкции управления циклами:
цикл while ;
цикл do ;
цикл for.
Цикл while
Основная форма цикла while может быть представлена так:
while(логическое выражение)
повторяющееся выражение, или блок;
В данной языковой конструкции повторяющееся выражение, или блок будет исполняться до тех пор, пока логическое выражение будет иметь истинное значение. Этот многократно исполняемый блок называют телом цикла
Операторы continue и break могут изменять нормальное исполнение тела цикла. Так, если в теле цикла встретился оператор continue, то операторы, следующие за ним, будут пропущены и выполнение цикла начнется сначала. Если continue используется с меткой и метка принадлежит к данному while, то выполнение его будет аналогичным. Если метка не относится к данному while, его выполнение будет прекращено и управление будет передано на оператор, или блок, к которому относится метка.
Если встретился оператор break, то выполнение цикла будет прекращено.
Если выполнение блока было прекращено по какой-то другой причине (возникла исключительная ситуация), то выполнение всего цикла будет прекращено по той же причине.
Рассмотрим несколько примеров:
public class Test {
static int x = 5;
public Test() { }
public static void main(String[] args) {
Test t = new Test();
int x = 0;
while(x < 5) {
x++;
if(x % 2 == 0) continue;
System.out.print(" " + x);
}
}
}
На консоль будет выведено
1 3 5
т.е. вывод на печать всех четных чисел будет пропущен.
public class Test {
static int x = 5;
public Test() { }
public static void main(String[] args) {
Test t = new Test();
int x = 0;
int y = 0;
lbl: while(y < 3) {
y++;
while(x < 5) {
x++;
if(x % 2 == 0) continue lbl;
System.out.println("x=" + x + " y="+y);
}
}
}
}
На консоль будет выведено
x=1 y=1
x=3 y=2
x=5 y=3
т.е. при выполнении условия if(x % 2 == 0) continue lbl; цикл по переменной x будет прерван, а цикл по переменной y начнет новую итерацию.
Типичный вариант использования выражения while():
int i = 0;
while( i++ < 5) {
System.out.println("Counter is " + i);
}
Следует помнить, что цикл while() будет выполнен только в том случае, если на момент начала его выполнения логическое выражение будет истинным. Таким образом, при выполнении программы может иметь место ситуация, когда цикл while() не будет выполнен ни разу.
boolean b = false;
while(b) {
System.out.println("Executed");
}
В данном случае строка System.out.println("Executed");
выполнена не будет.
Цикл do
Основная форма цикла do имеет следующий вид:
do
повторяющееся выражение или блок;
while(логическое выражение)
Цикл do будет выполняться до тех пор, пока логическое выражение будет истинным. В отличие от цикла while, этот цикл будет выполнен, как минимум, один раз.
Типичная конструкция цикла do:
int counter = 0;
do {
counter ++;
System.out.println("Counter is "
+ counter);
}
while(counter < 5);
В остальном выполнение цикла do аналогично выполнению цикла while, включая использование операторов break и continue.
Цикл for
Довольно часто бывает необходимо изменять значение какой-либо переменной в заданном диапазоне и выполнять повторяющуюся последовательность операторов с использованием этой переменной. Для выполнения такой последовательности действий как нельзя лучше подходит конструкция цикла for.
Основная форма цикла for выглядит следующим образом:
for(выражение инициализации; условие;
выражение обновления)
повторяющееся выражение или блок;
Ключевыми элементами данной языковой конструкции являются предложения, заключенные в круглые скобки и разделенные точкой с запятой.
Выражение инициализации выполняется до начала выполнения тела цикла. Чаще всего используется как некое стартовое условие (инициализация, или объявление переменной).
Условие должно быть логическим выражением и трактуется точно так же, как логическое выражение в цикле while(). Тело цикла выполняется до тех пор, пока логическое выражение истинно. Как и в случае с циклом while(), тело цикла может не исполниться ни разу. Это происходит, если логическое выражение принимает значение "ложь" до начала выполнения цикла.
Выражение обновления выполняется сразу после исполнения тела цикла и до того, как проверено условие продолжения выполнения цикла. Обычно здесь используется выражение инкрементации, но может быть применено и любое другое выражение.
Пример использования цикла for():
... for(counter=0;counter<10;counter++) {
System.out.println("Counter is "
+ counter);
}
В данном примере предполагается, что переменная counter была объявлена ранее. Цикл будет выполнен 10 раз и будут напечатаны значения счетчика от 0 до 9.
Разрешается определять переменную прямо в предложении:
for(int cnt = 0;cnt < 10; cnt++) {
System.out.println("Counter is " + cnt);
}
Результат выполнения этой конструкции будет аналогичен предыдущему. Однако нужно обратить внимание, что область видимости переменной cnt будет ограничена телом цикла.
Любая часть конструкции for() может быть опущена. В вырожденном случае мы получим оператор for с пустыми значениями
for(;;) {
...
}
В данном случае цикл будет выполняться бесконечно. Эта конструкция аналогична конструкции while(true) {
}
. Условия, в которых она может быть применена, мы рассмотрим позже.
Возможно также расширенное использование синтаксиса оператора for(). Предложение и выражение могут состоять из нескольких частей, разделенных запятыми.
for(i = 0, j = 0; i<5; i++, j+=2) {
...
}
Использование такой конструкции вполне правомерно.
Операторы break и continue
В некоторых случаях требуется изменить ход выполнения программы. В традиционных языках программирования для этих целей применяется оператор goto, однако в Java он не поддерживается. Для этих целей применяются операторы break и continue.
Оператор continue
Оператор continue может использоваться только в циклах while, do, for. Если в потоке вычислений встречается оператор continue, то выполнение текущей последовательности операторов (выражений) должно быть прекращено и управление будет передано на начало блока, содержащего этот оператор.
...
int x = (int)(Math.random()*10);
int arr[] = {....}
for(int cnt=0;cnt<10;cnt++) {
if(arr[cnt] == x) continue;
...
}
В данном случае, если в массиве arr встретится значение, равное x, то выполнится оператор continue и все операторы до конца блока будут пропущены, а управление будет передано на начало цикла.
Если оператор continue будет применен вне контекста оператора цикла, то будет выдана ошибка времени компиляции. В случае использования вложенных циклов оператору continue, в качестве адреса перехода, может быть указана метка, относящаяся к одному из этих операторов.
Рассмотрим пример:
public class Test {
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
for(int i=0; i < 10; i++) {
if(i % 2 == 0) continue;
System.out.print(" i=" + i);
}
}
}
В результате работы на консоль будет выведено:
i=1 i=3 i=5 i=7 i=9
При выполнении условия в строке 7 нормальная последовательность выполнения операторов будет прервана и управление будет передано на начало цикла. Таким образом, на консоль будут выводиться только нечетные значения.
Оператор break
Этот оператор, как и оператор continue, изменяет последовательность выполнения, но не возвращает исполнение к началу цикла, а прерывает его.
public class Test {
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
int [] x = {1,2,4,0,8};
int y = 8;
for(int cnt=0;cnt < x.length;cnt++) {
if(0 == x[cnt]) break;
System.out.println("y/x = " + y/x[cnt]);
}
}
}
На консоль будет выведено:
y/x = 8
y/x = 4
y/x = 2
При этом ошибки, связанной с делением на ноль, не произойдет, т.к. если значение элемента массива будет равно 0, то будет выполнено условие в строке 9 и выполнение цикла for будет прервано.
В качестве аргумента break может быть указана метка. Как и в случае с continue, нельзя указывать в качестве аргумента метки блоков, в которых оператор break не содержится.
Именованные блоки
В реальной практике достаточно часто используются вложенные циклы. Соответственно, может возникнуть ситуация, когда из вложенного цикла нужно прервать внешний. Простое использование break или continue не решает этой задачи, однако в Java можно именовать блок кода и явно указать операторам, к какому из них относится выполняемое действие. Делается это путем присвоения метки операторам do, while, for.
Метка - это любая допустимая в данном контексте лексема, оканчивающаяся двоеточием.
Рассмотрим следующий пример:
...
int array[][] = {...};
for(int i=0;i<5;i++) {
for(j=0;j<4; j++) {
...
if(array[i][j] == caseValue) break;
...
}
}
...
В данном случае при выполнении условия будет прервано выполнение цикла по j, цикл по i продолжится со следующего значения. Для того, чтобы прервать выполнение обоих циклов, используется метка:
...
int array[][] = {:..};
outerLoop: for(int i=0;i<5;i++) {
for(j=0;j<4; j++) {
...
if(array[i][j] == caseValue)
break outerLoop;
...
}
}
...
Оператор break также может использоваться с именованными блоками.
Между операторами break и continue есть еще одно существенное отличие. Оператор break может использоваться с любым именованным блоком, в этом случае его действие в чем-то похоже на действие goto. Оператор continue (как и отмечалось ранее) может быть использован только в теле цикла. То есть такая конструкция будет вполне приемлемой:
lbl: {
...
if( val > maxVal) break lbl;
...
}
В то время как оператор continue здесь применять нельзя. В данном случае при выполнении условия if выполнение блока с меткой lbl будет прервано, то есть управление будет передано на оператор (выражение), следующий непосредственно за закрывающей фигурной скобкой.
Метки используют пространство имен, отличное от пространства имен классов и методов.
Так, следующий пример кода будет вполне работоспособным:
public class Test {
public Test() {
}
public static void main(String[] args) {
Test t = new Test();
t.test();
}
void test() {
Test:
{
test: for(int i =0;true;i++) {
if(i % 2 == 0) continue test;
if(i > 10) break Test;
System.out.print(i + " ");
}
}
}
}
Для составления меток применяются те же синтаксические правила, что и для переменных, за тем исключением, что метки всегда оканчиваются двоеточием. Метки всегда должны быть привязаны к какому-либо блоку кода. Допускается использование меток с одинаковыми именами, но нельзя применять одинаковые имена в пределах видимости блока. Т.е. такая конструкция допустима:
lbl: {
...
System.out.println("Block 1");
...
}
...
lbl: {
...
System.out.println("Block 2");
...
}
А такая нет:
lbl: {
...
lbl: {
...
}
...
}
Оператор return
Этот оператор предназначен для возврата управления из вызываемого метода в вызывающий. Если в последовательности операторов выполняется return, то управление немедленно (если это не оговорено особо) передается в вызывающий метод. Оператор return может иметь, а может и не иметь аргументов. Если метод не возвращает значений (объявлен как void ), то в этом и только этом случае выражение return применяется без аргументов. Если возвращаемое значение есть, то return обязательно должен применяться с аргументом, чье значение и будет возвращено.
В качестве аргумента return может использоваться выражение
return (x*y +10) / 11;
В этом случае сначала будет выполнено выражение, а затем результат его выполнения будет передан в вызывающий метод. Если выражение будет завершено ненормально, то и оператор return будет завершен ненормально. Например, если во время выполнения выражения в операторе return возникнет исключение, то никакого значения метод не вернет, будет обрабатываться ошибка.
В методе может быть более одного оператора return.
Оператор synchronized
Этот оператор применяется для исключения взаимного влияния нескольких потоков при выполнении кода, он будет подробно рассмотрен в лекции 12, посвященной потокам исполнения.
Ошибки при работе программы. Исключения (Exceptions)
При выполнении программы могут возникать ошибки. В одних случаях это вызвано ошибками программиста, в других - внешними причинами. Например, может возникнуть ошибка ввода/вывода при работе с файлом или сетевым соединением. В классических языках программирования, например, в С, требовалось проверять некое условие, которое указывало на наличие ошибки, и в зависимости от этого предпринимать те или иные действия.
Например:
...
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, несовместимость с операционной системой, аппаратный сбой и многое другое. Все же современные виртуальные машины реализованы довольно хорошо и подобные сбои происходят крайне редко (при условии использования качественных комплектующих).
Аналогичная ситуация наблюдается и в случае с принудительной остановкой потоков исполнения. Поскольку это действие выполняется операционной системой, никогда нельзя предсказать, в каком именно месте остановится поток. Это означает, что программа может многократно отработать корректно, а потом неожиданно дать сбой просто из-за того, что поток остановился в каком-то другом месте. По этой причине принудительная остановка не рекомендуется. В лекции 12 рассматриваются примеры корректного управления жизненным циклом потока.
При возникновении исключительной ситуации управление передается от кода, вызвавшего исключительную ситуацию, на ближайший блок 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) {
...
}
Рис. 10.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 LegalThree 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() верное (новый метод не может выбрасывать ошибок, а значит, не расширяет список возможных ошибок старого метода);
* определение класса LegalThree будет корректным, так как переопределение метода method() будет верным (новый метод может создавать исключения, которые являются подклассами исключения, возбуждаемого в старом методе, то есть список сузился);
* определение класса IllegalOne будет некорректным, так как переопределение метода 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: anotherBogus.file (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
Несложно заметить, что, хотя последовательность вызовов одна и та же, в вызываемом и вызывающем методах обрабатываются разные объекты исключений.
Заключение
В данной лекции рассмотрены основные языковые конструкции.
Для организации циклов в Java предназначены три основных конструкции: while, do, for. Для изменения порядка выполнения операторов применяются continue и break (с меткой или без). Также существуют два оператора ветвления: if и switch.
Важной темой является обработка ошибок, поскольку без нее не обходится ни одна программа, ведь причиной сбоев может служить не только ошибка программиста, но и внешние события, например, разрыв сетевого соединения. Основной конструкцией обработки исключительных ситуаций является try-catch-finally. Для явной инициализации исключительной ситуации служит ключевое слово throw.
Ошибки делятся на проверяемые и непроверяемые. Чтобы повысить надежность программы, компилятор требует обработки исключений, классы которых наследуются от Exception, кроме классов-наследников RuntimeException. Предполагается, что такие ошибки могут возникать не столько по ошибке разработчика, сколько по внешним неконтролируемым причинам.
Классы, унаследованные от RuntimeException, описывают программные сбои. Ожидается, что программист сведет вероятность таких ошибок к минимуму, а потому, чтобы не загромождать код, они являются непроверяемыми, компилятор оставляет обработку на усмотрение разработчика. Ошибки-наследники Error свидетельствуют о фатальных сбоях, поэтому их также необязательно обрабатывать.