М. Бен-Ари Языки программирования. Практический сравнительный анализ. Предисловие

Вид материалаДокументы

Содержание


Исключительные ситуации
11.2. Исключения в PL/I
11.3. Исключения в Ada
11.4. Исключения в C++
11.5. Обработка ошибок в языке Eiffei
Типы перечисления
Проектирование по контракту
Подобный материал:
1   ...   7   8   9   10   11   12   13   14   ...   18
Глава 11


Исключительные ситуации


11.1. Требования обработки исключительных ситуаций

Ошибка во время выполнения программы называется исключительной ситуа­цией или просто исключением (exception). Когда программы исполнялись не интерактивно (offline), соответствующая реакция на исключительную ситуа­цию состояла в том, чтобы просто напечатать сообщение об ошибке и завер­шить выполнение программы. Однако реакция на исключение в интерактив­ной среде не может быть ограничена сообщением, а должна также включать восстановление, например возврат к той точке, с которой пользователь может повторить вычисление или, по крайней мере, выбрать другой вариант. Про­граммное обеспечение, которое используется в таких встроенных системах, как системы управления летательными аппаратами, должно выполнять вос­становление при ошибках без вмешательства человека. Обработка исклю­чений до недавнего времени, как правило, не поддерживалась в языках про­граммирования; использовались только средства операционной системы. В этом разделе будут описаны некоторые механизмы обработки исключений, которые существуют в современных языках программирования.

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

Какие свойства делают средства обработки исключений хорошими?


• В случае отсутствия исключения издержки должны быть очень неболь­шими.


• Обработка исключения должна быть легкой в использовании и безопас­ной.


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

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



Ada
if Ptr.Next= null then . . . else . . .


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

В качестве элементарной обработки исключений в некоторых языках пользователю дана возможность определять блок кода, который будет вы­полнен перед завершением программы. Это полезно для наведения порядка (закрытия файлов и т.д.) перед выходом из программы. В языке С средство setjmp/longjmp позволяет пользователю задать дополнительные точки внут­ри программы, в которые обработчик исключений может возвращаться. Этого типа обработки исключений достаточно, чтобы гарантировать, что программа «изящно» завершится или перезапустится, но он недостаточно гибок для детализированной обработки исключений.

Обратите внимание, что, согласно нашему определению исключения как непредвиденной ошибки на этапе выполнения, в языке С «исключений» меньше, чем в таком языке, как Ada. Во-первых, такие ошибки, как выход за границы массива, не определены в языке С; они просто являются ошибками программиста, которые не могут быть «обработаны». Во-вторых, поскольку в С нет гибкого средства обработки исключений, каждая возможность языка, которая запрашивается через подпрограмму, возвращает код, указывающий, был запрос успешным или нет. Таким образом, в языке Ada распределение па­мяти с помощью new может вызвать исключительную ситуацию, если нет до­статочного объема памяти, тогда как в С malloc возвращает код, который дол­жен быть проверен явно. Выводы для стиля программирования следующие: в Ada можно использовать new обычным порядком, а обработку исключений проектировать независимо, в то время как в С полезно написать подпрограм-му-оболочку для malloc так, чтобы реакцию на исключительные ситуации можно было разработать и запрограммировать централизованно, вместо того чтобы разрешать каждому члену группы тестировать (или забывать тестиро­вать) нехватку памяти:


void* get_memory(int n)


C
{

void* p = malloc(n);

if (p == 0) /* Выделение памяти потерпело неудачу */

/* Сделайте что-нибудь или корректно

завершите работу */

return р;

}


11.2. Исключения в PL/I


PL/1 был первым языком, который содержал средство для обработки исклю-чительных ситуаций в самом языке — блок «при наступлении события» или, |коротко, «при» (on-unit). Он является блоком кода, который выполняется, ког-да возникает исключительная ситуация; после его завершения вычисление продолжается. Проблема в PL/1, связанная с блоком «при», состоит в том, что он влияет на обычные вычисления. Предположим, что активизирован блок, Срабатывающий при потере значимости с плавающей точкой. Тогда потенци-ально возможно воздействие на каждое выражение с плавающей точкой; дру-гими словами, каждое выражение с плавающей точкой содержит неявный вы-|зов блока и возврат из него. Это затрудняет выполнение оптимизации сохра-нения значений в регистрах или вынесения общих подвыражений.


11.3. Исключения в Ada


В языке Ada определен очень простой механизм обработки исключений, ко-

торый послужил моделью для других языков.

В Ada есть четыре предопределенных исключения:


Constraint_Error (ошибка ограничения). Нарушение ограничивающих ус-ловий, например, когда индексация массива выходит за границы или вы- бор вариантного поля не соответствует дискриминанту.


Storage_Error (ошибка памяти). Недостаточно памяти.


Program_Error (программная ошибка). Нарушение правил языка, напри-

мер выход из функции без выполнения оператора return.


Tasking_Error (ошибка задачи). Ошибки, возникающие при взаимодейст- вии задач

(см. гл. 12).


Конечно, Constraint_Error — наиболее часто встречающееся исключение, связанное со строгим контролем соответствия типов в языке Ada. Кроме того,

программист может объявлять исключения, которые обрабатываются точно

так же, как и предопределенные исключения.

Когда исключительная ситуация наступает, в терминологии языка Ada —возбуждается (raised), вызывается блок кода, называемый обработчиком иск­лючения (exeption handler). В отличие от PL/I вызов обработчика завершает включающую процедуру. Так как обработчик не возвращается к нормальному вычислению, нет никаких помех для оптимизации. В отличие от обработчи­ков глобальных ошибок в С, обработка исключительных ситуаций в Ada чрез­вычайно гибкая, потому что обработчики исключений могут быть привязаны к любой подпрограмме:


procedure Main is

procedure Proc is

P: Node_Ptr;

begin

P := new Node; -- Может возбуждаться исключение

Statement_1; -- Пропускается, если возбуждено исключение

exception

when Storage_Error =>

-- Обработчик исключения

end Proc; begin Proc; Statement_2; — Пропускается, если исключение распространилось

из Proc

exception

when Storage_Error =>

-- Обработчик исключения

end Main;

После последнего исполняемого оператора подпрограммы ключевое слово exception вводит последовательность обработчиков исключений — по одному для каждого вида исключений. Когда возбуждается исключение, процедура покидается, и вместо нее выполняется обработчик исключения. Когда обра­ботчик завершает свою работу, выполняется нормальное завершение процедуры. В приведенном примере программа выделения памяти может по­родить исключительную ситуацию Storage_Error, в этом случае Statement_1 пропускается, и выполняется обработчик исключения. После завершения об­работчика и нормального завершения процедуры главная программа продол­жается с оператора Statement_2.

Семантика обработки исключений предоставляет программисту большую гибкость в управлении обработкой исключительных ситуаций:

• Если исключительная ситуация не обработана внутри процедуры, попытка ее выполнения оставляется, и исключительная ситуация возбуждается снова в точке вызова. При отсутствии обработчика в Proc исключение повторно было бы возбуждено в Main, оператор Statement_2 был бы пропущен и выполнен обработчик в Main.


• Если исключительная ситуация возбуждается во время выполнения обра­ботчика, обработчик оставляется, и исключение возбуждается снова в точке вызова.


• У программиста есть выбор: возбудить то же самое или другое исклю­чение в точке вызова. Например, мы могли бы перевести предопре­деленное исключение типа Storage_Error в исключение, определенное в прикладной программе. Это делается с помощью оператора rais в обра­ботчике:


exception

when Storage_Error =>

… -- Обрабатывается исключение, затем

raise Overflow; --Возбуждается исключение Overflow в точке вызова


Обработчик для others может использоваться, чтобы обработать все исклю­чения, которые не упомянуты в предыдущих обработчиках.

Если даже в главной программе нет обработчика для исключения, оно об­рабатывается системой поддержки выполнения, которая обычно прерывает выполнение программы и выдает сообщение. Хорошим стилем програм­мирования можно считать такой, при котором все исключения гарантиро­ванно обрабатываются хотя бы на уровне главной программы.

Определение исключений в языке Ada 83 не позволяло обработчику иметь доступ к информации о ситуации. Если более одной исключительной ситуа­ции обрабатываются одинаково, никаким способом нельзя было узнать, что же именно произошло:


exception

when Ех_1 | Ех_2 | Ех_3 =>

--Какое именно исключение произошло?


Язык Ada 95 позволяет обработчику исключительной ситуации иметь па­раметр:


exception

when Ex: others =>


Всякий раз при возбуждении исключения параметр Ех будет содержать информацию, идентифицирующую исключение, а предопределенные проце­дуры позволят программисту отыскать информацию относительно исклю­чения. Эта информация также может быть определена программистом (см. справочное руководство по языку, раздел 11.4.1).


Реализация


Реализуются обработчики исключений очень эффективно. Процедура, кото­рая содержит обработчики исключений, имеет дополнительное поле в записи активации с указателем на обработчики (см. рис. 11.1). Требуется только одна команда при вызове процедуры, чтобы установить это поле, вот и все издержки при отсутствии исключений.





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


11.4. Исключения в C++


Обработка исключений в C++ во многом сходна с той, которая применяется в языке Ada, а именно, исключение можно явно возбудить, обработать соот­ветствующим обработчиком (если он есть), после чего блок (подпрограмма) окажется завершенным. Отличия в следующем:


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


• Исключения идентифицируются типом параметра, а не именем. Имеет­ся специальный эквивалент синтаксиса others для обработки исклю­чений, не упомянутых явно.


• Можно создавать семейства исключений, используя наследование (см. гл. 14).


• Если в языке Ada для исключения в программе не предусмотрен обра­ботчик, то вызывается системный обработчик. В C++ программист мо­жет определить функцию terminate(), которая вызывается, когда исклю­чение не обработано.


В следующем примере блок try идентифицирует область действия последова­тельности операторов, для которых обработчики исключений (обозначенные как catch-блоки) являются активными. Throw-оператор приводит к возбужде­нию исключений; в этом случае оно будет обработано вторым catch-блоком, так как строковый параметр throw-оператора соответствует параметру char* второго catch-блока:


void proc()

{

… // Исключения здесь не обрабатываются

try {



throw "Invalid data"; // Возбудить исключение

}

catch (int i) {

… // Обработчик исключения

}

catch (char *s) {

… // Обработчик исключения

}

catch (...) { // Прочие исключения

…. // Обработчик исключений

}

}


Как в Ada, так и в C++ допускается, чтобы обработчик вызывался для исклю­чения, которое он не может видеть, потому что оно объявлено в теле пакета (Ada), или тип объявлен как private в классе (C++). Если исключение не обра­ботано и в others (или ...), то оно будет неоднократно повторно возбуждаться до тех пор, пока, наконец, с ним не обойдутся как с необработанным исклю­чением. В C++ есть способ предотвратить такую неопределенность поведе­ния с помощью точного объявления в подпрограмме, какие исключительные ситуации она готова обрабатывать:


void proc() throw (t1 , t2, t3);


Такая спецификация исключений (exception specifications) означает, что отсутствующие в списке исключения, которые, возможно, будут возбуж­даться, но не будут обрабатываться в ргос (или любой подпрограмме, вызван­ной из ргос), немедленно вызывут глобально определенную функцию unex-pectedQ вместо того, чтобы продолжать поиск обработчика. В больших системах эта конструкция полезна для документирования полного интерфейса подпрограмм, включая исключения, которые будут распространяться.


11.5. Обработка ошибок в языке Eiffei


Утверждения

В языке Eiffei подход к обработке исключений основан на концепции, что, прежде всего, ошибок быть не должно. Конечно, все программисты борются за это, и особенность языка Eiffei состоит в том, что в него включена поддер­жка определения правильности программы. Она основана на понятии утвер­ждений (assertions), которые являются логическими формулами и обычно ис­пользуются для формализации программы, но не являются непосредственно частью ее (см. раздел 2.2). Каждая подпрограмма, называемая рутиной (rou­tine) в Eiffei, может иметь связанные с ней утверждения. Например, подпро­грамма для вычисления результата (result) и остатка (remainder) целочисленно­го деления делимого (dividend) на делитель (divisor) была бы написана следую­щим образом:


integer_division(dividend, divisor, result, remainder: INTEGER) is

require

divisor > 0

do

from

result = 0; remainder = dividend;

invariant

dividend = result*divisor + remainder

variant

remainder

until

remainder < divisor

loop

remainder := remainder - divisor;

result := result + 1 ;

end

ensure

dividend = result*divisor + remainder;

remainder < divisor

end


Конструкция require (требуется) называется предусловием и специ­фицирует, какие выходные данные подпрограмма считает правильными. Конструкция do (выполнить) содержит выполняемые операторы, собственно и составляющие программу. Конструкция ensure (гарантируется) называется постусловием и содержит. условия, истинность которых подпрограмма обещает обеспечить, если будет выполнена конструкция do над данными, удовлетворяющими предусловию. В данном случае справедливость постус­ловия является достаточно тривиальным следствием инварианта (см. 6.6) и условия until.

На большем масштабе вы можете присоединить инвариант к классу (см. раздел 15.4). Например, класс, реализующий стек с помощью массива, вклю­чал бы инвариант такого вида:


invariant

top >= 0;

top < max;


Все подпрограммы класса должны гарантировать, что инвариант истинен, когда объект класса создан, и что каждая подпрограмма сохраняет истиность инварианта. Например, подпрограмма pop имела бы предусловие top> 0, в противном случае выполнение оператора:


top := top - 1


могло бы нарушить инвариант.


Типы перечисления


Инварианты применяются также, чтобы гарантировать безопасность типа, которая достигается в других языках использованием типов перечисления. Следующие объявления в языке Ada:



Ada
type Heat is (Off, Low, Medium, High);

Dial: Heat;


были бы записаны на языке Eiffel как обычные целые переменные с имено­ванными константами:


Dial: Integer;

Off: Integer is 0;

Low: Integer is 1;

Medium: Integer is 2;

High: Integer is 3;


Инвариант гарантирует, что бессмысленные присваивания не выполнятся:


invariant

Off <= Dial <= High


Последняя версия языка Eiffel включает уникальные константы (unigue con­stants), похожие на имена перечисления в том отношении, что их фактические значения присваиваются компилятором. Однако они по-прежнему остаются целыми числами, поэтому безопасность типа должна по-прежнему обеспе­чиваться с помощью утверждений: постусловие должно присоединяться к лю­бой подпрограмме, которая изменяет переменные, чьи значения должны быть ограничены этими константами.


Проектирование по контракту


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

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


Исключения


Подпрограммы Eiffel могут содержать обработчики исключений:


proc is

do

… -- Может возбуждаться исключение

rescue

… -- Обработчик исключения

end;


Когда возбуждается исключение, считается, что подпрограмма потерпела не­удачу, и выполняются операторы после rescue. В отличие от языка Ada, после завершения обработчика исключение порождается снова в вызывающей про­грамме. Это эквивалентно завершению в Ada обработчика исключения raise-оператором, который повторно порождает в вызывающей подпрограмме то же самое исключение, которое заставило вызвать обработчик.

Мотивировка такого решения в предположении, что постусловие подпрограммы (и/или инвариант класса) удовлетворяются.

Если это не так, то вам, возможно, захочется получить уведомление, но уж наверняка вы не можете удовлетворить постусловие, т. е. потерпели неудачу при выполнении работы, которую заказала вам вызывающая под­программа. Другими словами, если.вам известно, как справиться с проблемой и удовлетворить постусловие, то предусмотрите это в подпро­грамме. Это аналогично нашему совету не пользоваться исключениями вместо операторов if.

Обработчик исключения для помощи в решении возникших проблем может вносить некоторые изменения и запрашивать повторное выполнение подпрограммы с самого начала, если в него включено ключевое слово retry в качестве последнего оператора. Новая попытка может привести или не привести к успеху. Принципиально здесь то, что успешное выполнение — это нормальное завершение подпрограммы с выполненным постусловием. В про­тивном случае ее выполнение терпит неудачу.

Обработчик исключений в языке Ada можно смоделировать в Eiffel следу­ющим образом, хотя это идет вразрез с философией языка:


proc is

local

tried: Boolean; -- Инициализировано как false;

do

if not tried then

-- Обычная обработка

-- Порождает исключения

else

-- «Обработчик исключения»

end

rescue

if not tried then

tried := true; -- Чтобы не было двойного повтора

retry

end

end;


11.6. Упражнения


1. Пакет языка Ada. Исключения в Ada 95 определяют типы и подпрограм­мы для сопоставления информации с исключениями. Сравните эти кон­струкции с конструкциями throw и catch в C++.


2. Покажите, что исключение в языке Ada может быть порождено вне об­ласти действия исключения. (Подсказка: см. гл. 13.) Как можно обработать исключение, объявление которого не находится в области дей­ствия?


3. Покажите, как описание исключений на языке C++: void proc() throw(t1, t2, t3); может быть смоделировано с помощью многократных catch-блоков.


4. Изучите класс EXCEPTION в языке Eiffel и сравните его с обработчиком исключения в языке Ada.