Когда контракт нарушается: обработка исключений
Примеры обработки исключений
Теперь, когда у нас есть базисный механизм, давайте посмотрим, как он применяется в общих ситуациях.
Поломки при вводе
Предположим, что в интерактивной системе необходимо выдать подсказку пользователю, от которого требуется ввести целое. Пусть только одна процедура занимается вводом целых - read_one_integer, которая результат ввода присваивает атрибуту last_integer_read. Эта процедура работает неустойчиво, - если на ее входе будет нечто, отличное от целого, она может привести к отказу, выбрасывая исключение. Конечно, вы не хотите, чтобы это событие приводило к отказу всей системы. Но поскольку вы не управляете программой ввода, то следует ее использовать и организовать восстановление ситуации, при возникновении исключений. Вот возможная схема:
get_integer is -- Получить целое от пользователя и сделать его доступным в -- last_integer_read. -- Если ввод некорректен, запросить повторения, столько раз, -- сколько необходимо. do print ("Пожалуйста, введите целое: ") read_one_integer rescue retry end
Эта версия программы иллюстрирует стратегию повторения.
Очевидный недостаток - пользователь упорно вводит ошибочное значение, программа упорно запрашивает значение. Это не очень хорошее решение. Можно ввести верхнюю границу, скажем 5, числа попыток. Вот пересмотренная версия:
Maximum_attempts: INTEGER is 5 -- Число попыток, допустимых при вводе целого. get_integer is -- Попытка чтения целого, делая максимум Maximum_attempts попыток. -- Установить значение integer_was_read в true или false -- в зависимости от успеха чтения. -- При успехе сделать целое доступным в last_integer_read. local attempts: INTEGER do if attempts < Maximum_attempts then print ("Пожалуйста, введите целое: ") read_one_integer integer_was_read := True else integer_was_read := False end rescue attempts := attempts + 1 retry end
Предполагается, что включающий класс имеет булев атрибут integer_was_read.
Вызывающая программа должна использовать эту программу следующим образом, пытаясь введенное целое присвоить сущности n:
get_integer if integer_was_read then n := last_integer_read else "Иметь дело со случаем, в котором невозможно получить целое" end
Восстановление при исключениях, сгенерированных операционной системой
Среди событий, включающих исключения, есть сигналы, посылаемые операционной системой, некоторые из которых являются следствием аппаратных прерываний. Примеры: арифметическое переполнение сверху и снизу, невозможные операции ввода-вывода, запрещенные команды, обращение к недоступной памяти, прерывания от пользователя (например, нажата клавиша break).
Теоретически можно рассматривать такие условия, как нарушение утверждений. Если a+b приводит к переполнению, то это означает, что вызов не удовлетворяет неявному предусловию функции + для целых или вещественных чисел, устанавливающее, что сумма двух чисел должна быть представима в компьютере. Подобное неявное предусловие задается при создании новых объектов (создание копии) - памяти должно быть достаточно. Отказы встречаются из-за того, что окружение - файлы, устройства, пользователи - не отвечают условиям применимости. Но в таких случаях непрактично или невозможно задавать утверждения, допуская их независимую проверку. Единственное решение - пытаться выполнить операцию, и, если аппаратура или операционная система выдает сигнал о ненормальном состоянии, рассматривать его как исключение.
Рассмотрим проблему написания функции quasi_inverse, возвращающей для каждого вещественного x обратную величину 1/x или 0, если x слишком мало.
Подобные задачи по существу нельзя реализовать, не используя механизм исключений. Единственный практичный способ узнать, можно ли для данного x получить обратную величину, - это выполнить деление. Но деление может спровоцировать переполнение, и если нет механизма управления исключениями, то программа завершится отказом, и будет слишком поздно возвращать 0 в качестве результата.
Механизм rescue-retry позволяет просто решить эту проблему, по крайней мере, на платформе, включающей сигнал при арифметическом переполнении:
quasi_inverse (x: REAL): REAL is -- 1/x, если возможно, иначе 0 local division_tried: BOOLEAN do if not division_tried then Result := 1/x end rescue division_tried := True retry end
Правила инициализации устанавливают значение false для division_tried в начале каждого вызова. В теле не нужно предложение else, поскольку инициализация установит Result равным 0.
Повторение программы, толерантной к неисправностям
Предположим, вы написали текстовый редактор, и к вашему стыду нет уверенности, что он полностью свободен от жучков. Но вам хочется передать эту версию некоторым пользователям для получения обратной связи. Нашлись смельчаки, готовые принять систему с оставшимися ошибками, понимая, что могут возникать ситуации, когда их запросы не будут выполнены. Но они не будут тестировать ваш редактор на серьезных текстах, (а именно это вам и требуется), если будут бояться, что отказы могут привести к катастрофе, например грубый выход с потерей текста, над которым шла работа последние полчаса. Используя механизм повторения, можно обеспечить защиту от такого поведения.
Предположим, что редактор, как это обычно бывает для подобных систем, содержит основной цикл, выполняющий команды редактора:
from ... until exit loop execute_one_command end
где тело программы execute_one_command имеет вид:
"Декодировать запрос пользователя" "Выполнить команду, реализующую запрос"
Инструкция "Выполнить ..." выбирает нужную программу (например, удалить строку, заменить слово и так далее). Мы увидим в последующих лекциях, как техника наследования и динамическое связывание дает простые, элегантные структуры для подобных ветвящихся решений.
Будем исходить из того, что не все эти программы являются безопасными. Некоторые из них могут отказать в непредсказуемое время. Вы можете обеспечить примитивную, но эффективную защиту против таких событий, написав программу следующим образом:
execute_one_command is -- Получить запрос от пользователя и, если возможно, -- выполнить соответствующую команду. do "Декодировать запрос пользователя" "Выполнить подходящую команду в ответ на запрос" rescue message ("Извините, эта команда отказала") message ("Пожалуйста, попробуйте использовать другую команду") message ("Пожалуйста, сообщите об отказе автору") "Команды, латающие состояние редактора" retry end
Эта схема предполагает на практике, что поддерживаемые запросы пользователя включают: "сохранить текущее состояние работы", "завершить работу". Оба последних запроса должны работать корректно. Пользователь, получивший сообщение "Извините...", несомненно, захочет сохранить работу и выйти как можно скорее. Некоторые из программ, реализующих команды редактора, могут иметь собственные предложения rescue, хотя и приводящие к отказу, но предварительно выдающие более информативные сообщения.
N-версионное программирование
Другим примером повторения программы, толерантной к неисправностям, является реализация N-версионного программирования - подхода, улучшающего надежность ПО.
В основе N-версионного программирования лежит идея избыточности, доказавшая свою полезность в аппаратуре. В критически важных областях зачастую применяется дублирование аппаратуры, например, несколько компьютеров выполняют одни и те же вычисления, и есть компьютер-арбитр, сравнивающий результаты, и принимающий окончательное решение, если большинство компьютеров дало одинаковый результат. Этот подход хорошо защищает от случайных отказов в аппаратуре отдельного устройства. Он широко применяется в аэрокосмической области. (Известен случай, когда при запуске космического челнока сбой произошел в компьютере-арбитре). N-версионное программирование переносит этот подход на разработку ПО в критически важных областях. В этом случае создаются несколько программистских команд, каждая из которых независимо разрабатывает свою версию системы (программы). Предполагается, что ошибки, если они есть, будут у каждой команды свои.
Это спорная идея; возможно, лучше вложить средства в одну версию, добиваясь ее корректности, чем финансировать две или три несовершенных реализации. Проигнорируем, однако, эти возражения, пусть о полезности идеи судят другие. Нас будет интересовать возможность использования механизма retry в ситуации, где есть несколько реализаций, и используется первая из них, не заканчивающаяся отказом:
do_task is -- Решить проблему, применяя одну из нескольких возможных реализаций. require ... local attempts: INTEGER do if attempts = 0 then implementation_1 elseif attempts = 1 then implementation_2 end ensure ... rescue attempts := attempts + 1 if attempts < 2 then "Инструкции, восстанавливающие стабильное состояние" retry end end
Обобщение на большее, чем две, число реализаций очевидно.
Этот пример демонстрирует типичное использование retry. Предложение rescue никогда не пытается достигнуть исходной цели, запуская, например, очередную реализацию. Достижение цели - привилегия нормального тела программы.
Заметьте, после двух попыток (в общем случае n попыток) предложение rescue достигает конца, не вызывая retry, следовательно, приводит к отказу.
Давайте рассмотрим более тщательно, что случается, когда включается исключение во время выполнения r. Нормальное выполнение (тела) останавливается; вместо этого начинает выполняться предложение rescue. После чего могут встретиться два случая:
- Предложение rescue выполнит в конечном итоге retry. В этом случае начнется повторное выполнение тела программы. Эта новая попытка может быть успешной, тогда программа нормально завершится и управление вернется к клиенту. Вызов успешен, контракт выполнен. За исключением того, что вызов мог занять больше времени, никакого другого влияния появление исключения не оказывает. Если, однако, повторная попытка снова приводит к исключению, то вновь начнет работать предложение rescue.
- Если предложение rescue не выполняет retry, оно завершится естественным образом, достигнув end. (В последнем примере это происходит, когда attempts >=2.) В этом случае программа завершается отказом; она возвращает управление клиенту, сигнализируя о неудаче выбрасыванием исключения. Поскольку клиент должен обработать возникшее исключение, то снова возникают два рассмотренных случая, теперь уже на уровне клиента.
Этот механизм строго соответствует принципу Дисциплинированной Обработки Исключения. Программа завершается либо успехом, либо отказом. В случае успеха ее тело выполняется до конца и гарантирует выполнение постусловия и инварианта. Когда выполнение прерывается исключением, то можно либо уведомить об отказе, либо попытаться повторно выполнить нормальное тело. Но нет никакой возможности выхода из предложения rescue, уведомив клиента, что все завершилось нормально.