Основы объектно-ориентированного программирования

         

Базисные концепции обработки исключений


Литература по обработке исключений зачастую не очень точно определяет, что вызывает исключение. Как следствие, механизм исключений, представленный в таких языках программирования как PL/I и Ada, часто неправильно используется: вместо того, чтобы резервироваться только для истинно чрезвычайных ситуаций, они заканчивают службу как внутрипрограммные инструкции goto, нарушающие принцип Защищенности.

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



Библиографические замечания


[Liskov 1979] и [Cristian 1985] предлагали другие точки зрения на исключения. Многие из работ по ПО, толерантному к отказам, ведут начало от понятия "восстанавливающий блок" [Randell 1975]. Такой блок используется в задаче, когда основной алгоритм отказывается выдавать решение. Этим "восстанавливающий блок" отличается от предложения rescue, которое никогда не пытается достичь основной цели, хотя и может повторно запустить выполнение, предварительно "залатав" все повреждения.

[Hoare 1981] содержит критику механизма исключений Ada.

Подход к обработке исключений, разработанный в этой лекции, был впервые представлен в [M 1988e] и [M 1988].



Цепочка вызовов


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


Рис. 12.1.  Цепочка вызовов

Пусть r0 будет корневой процедурой некоторой системы (в Ada это программа main). В каждый момент выполнения есть текущая программа, вызванная последней и ставшая причиной исключения. Пройдем по цепочке в обратном порядке, начиная с текущей программы, от вызываемой к вызывающей программе. Реверсная цепочка (r0, последняя вызванная r0 программа r1, последняя вызванная r1 программа r2 и так далее до текущей программы) называется цепочкой вызовов.

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



Четкое разделение ролей


Интересно сравнить формальные роли тела и предложения Rescuer:

{prer and INV} Bodyr {postr (xr) INV} {True} Rescuer {INV}

Входное утверждение сильнее для Bodyr - в то время, когда Rescuer не накладывает никаких требований, перед началом выполнение тела программы (предложения do) должно выполняться предусловие и инвариант. Это упрощает работу Bodyr.

Выходное утверждение также сильнее для Bodyr - в то время, когда Rescuer обязана восстановить только инвариант класса, Bodyr обязана сыграть свою роль и обеспечить истинность выполнения постусловия. Это делает ее работу более трудной.

Эти правила отражают разделение ролей между предложением do и предложением rescue. Задача тела обеспечить выполнение контракта программы, не управляя непосредственно исключениями. Задача rescue - управлять обработкой исключениями, возвращая управление либо телу программы, либо вызывающей программе. Но в обязанности rescue не входит обеспечение контракта.



Дисциплинированные исключения


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

Исследуемый нами подход основан на метафоре контракта, - ни при каких обстоятельствах программа не должна претендовать на успешность, когда фактически имеет место отказ в достижении цели. Программа может быть либо успешной (возможно, после исправления ситуации и нескольких попыток retry), либо приводить к отказу.

Исключения в языках Ada, CLU, PL/1 не следуют этой модели. В языке Ada ее инструкция



Raise exc

прервет выполнение программы и возвратит управление вызывающей программе, которая может обработать исключение в специальном обработчике, или вернет управление на уровень выше. Но здесь нет правила, ограничивающего действия обработчика. Следовательно, полностью возможно игнорировать исключение или вернуть альтернативный результат. Это объясняет, почему некоторые разработчики смотрят на механизм исключений просто как на средство обработки специальных случаев, не включенных в основной алгоритм. Такие приложения исключения рассматривают фактически raise как goto, что, очевидно, опасно, так как позволяет передавать управление за границы программы. По моему мнению, они злоупотребляют механизмом.

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



Должны ли исключения быть объектами?


Фанатики объектной ориентации (многие ли из тех, кто открыл красоту этого подхода, не рискуют стать его фанатиками?) могут критиковать представленный механизм за то, что исключения не являются гражданами первого сорта в программном сообществе. Почему исключения не являются объектами?

В ОО-расширении Pascal в среде Delphi исключения действительно представлены объектами.

Не очень понятны преимущества такого решения. Некоторое обоснование можно будет найти в лекции 4 курса "Основы объектно-ориентированного проектирования", посвященной ответу на вопрос, каким должен быть класс. Объект является экземпляром абстрактно определенного типа данных, характеризуемого его компонентами. Исключение, конечно, как мы видели в классе EXCEPTIONS, имеет компоненты, заданные целочисленным кодом, текстовым сообщением. Но эти компоненты являются запросами, в то время, как истинные объекты имеют команды, изменяющие состояние объекта. Исключения не находятся под управлением программной системы; они результат событий, находящихся вне пределов ее достижимости.

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



Исключения


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

Определение: исключение

Исключение - событие периода выполнения, которое может стать причиной отказа программы.

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

Изучение программных аномалий в предыдущей лекции привело к появлению терминов неисправность (fault) - для событий, приводящих к пагубным последствиям при выполнении программы, дефект (defect) - неадекватность программной системы, способная привести к отказам, ошибка (error) - неверные решения разработчика или проектировщика, приводящие к дефектам. Отказ - это неисправность; исключение, зачастую, тоже неисправность, но таковым не является, если его возможное появление предвиделось, и программа может справиться с возникшей ситуацией.



Исключения разработчика


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

Такие исключения называются исключениями разработчика. Они характеризуются как целочисленным кодом, отличающимся от системных кодов, так и именем (строкой), которые могут быть использованы, например, в сообщениях об ошибке. Можно использовать следующие свойства для возбуждения исключения разработчика и для анализа его свойств в предложении rescue.

trigger (code: INTEGER; message: STRING) -- Прерывает выполнение текущей программы, выбрасывая исключение с кодом -- code и связанным текстовым сообщением. developer_exception_code: INTEGER -- Код последнего исключения разработчика developer_exception_name: STRING -- Имя, ассоциированное с последним исключением разработчика is_developer_exception: BOOLEAN -- Было ли последнее исключение исключением разработчика? is_developer_exception_of_name (name: STRING): BOOLEAN -- Имеет ли последнее исключение разработчика имя name? ensure Result := is_developer_exception and then equal (name, developer_exception_name)

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

set_developer_exception_context (c: ANY) -- Определить c как контекст, связанный с последовательностью -- исключений разработчика (причина вызова компонента trigger). require context_exists: c /= Void developer_exception_context: ANY -- Контекст, установленный последним вызовом set_developer_exception_context -- void, если нет такого вызова.

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



Источники исключений


Исключения можно классифицировать, разделив их на категории.

Определение: исключительные ситуации

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

Попытка квалифицированного вызова a.f и обнаружение, что a = Void.Попытка присоединить значение Void к развернутой (expanded) цели.Выполнение невозможной или запрещенной операции, обнаруживаемое аппаратно или операционной системой.Вызов программы, приводящей к отказу.Предусловие r не выполняется на входе.Постусловие r не выполняется на выходе.Инвариант класса не выполняется на входе или выходе.Инвариант цикла не выполняется в результате инициализации в предложении from или после очередной итерации тела цикла.Итерация тела цикла не уменьшает вариант цикла.Не выполняется утверждение инструкции check.Выполнение инструкции, явно включающей исключение.

Случай (1) отражает одно из основных требований к использованию ссылок: вызов a.f имеет смысл, когда к a присоединен объект, другими словами, когда a не void. Это обсуждалось в лекции 8 при рассмотрении динамической модели.

Случай (2) также имеет дело с void значениями. Напомним, что "присоединение" (attachment) покрывает присваивание и передачу аргументов, имеющих одинаковую семантику. В разделе "Гибридное присоединение" лекции 8 отмечалась возможность присваивания ссылки развернутой цели, в результате чего происходит копирование объекта. Но это предполагает существование объекта, но если источник void, то присоединение вызовет исключение.

Случай (3) следствие сигналов, посылаемых приложению операционной системой.

Случай (4) возникает при отказе программы, как результат возникновения в ней исключения, с которым она не смогла справиться. Более подробно это будет рассмотрено ниже, но пока обратите внимание на правило, вытекающее из (4):

Отказы и исключения

Отказ программы - причина появления исключения в вызывающей программе.

Случаи (5)-(10) могут встретиться только при мониторинге утверждений, включенных на соответствующем уровне: assertion (require) для (5), assertion (loop) для (8) и (9) и так далее.

Случай (11) предполагает вызов процедуры raise, выбрасывающей (зажигающей) исключения. Такая процедура будет рассмотрена чуть позднее.



Как не следует делать это - Ada пример


Приведу пример программы, взятый из одного учебника1) по языку Ada.

sqrt (x: REAL) return REAL is begin if x < 0.0 then raise Negative else normal_square_root_computation end exception when Negative => put ("Negative argument") return when others => ... end -- sqrt

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

Целью программы является получение вещественного квадратного корня из вещественного числа. Но что если число отрицательно? В языке Ada нет утверждений, так что в программе проводится проверка, возбуждающая исключение для отрицательных чисел.

Инструкция raise Exc прерывает выполнение текущей программы и включает исключение с кодом Exc. Это исключение может быть захвачено и обработано при наличии предложений exception, имеющих вид:

exception when code_a1, code_a2, ...=> Instructions_a; when code_b1, ... => Instructions_b; ...

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

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


Но что делают соответствующие инструкции? Посмотрите еще раз:

put ("Negative argument") return

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

Эта техника, вероятно, хуже, чем C-Unix сигнальный механизм, позволяющий, по крайней мере, возобновить вычисление в точке, где оно остановилось. Обработчик исключения when, заканчивающийся инструкцией return, даже не продолжает текущую программу; он возвращает управление вызывающей программе, будто бы все прекрасно, в то время как все далеко не прекрасно.

Этот контрпример дает хороший урок Ada-программистам: почти ни при каких обстоятельствах обработчик when не должен заканчиваться return. Слово "почти" употреблено для полноты картины, поскольку есть особый допустимый случай ложной тревоги (false alarm), достаточно редкий, который мы обсудим чуть позже. Опасно и неприемлемо не уведомлять вызывающую программу о возникшей ошибке. Если невозможно исправить ситуацию и выполнить контракт, то программа должна выработать отказ. Язык Ada позволяет сделать это: предложение exception может заканчиваться инструкцией raise без параметров, повторно выбрасывая исходное исключение, передавая его вызывающей программе. Это и есть подходящий способ завершения выполнения, когда невозможно выполнить свой контракт.

Правило исключений языка Ada

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


Как не следует делать это - C-Unix пример


Первым контрпримером механизма (наиболее полно представленным в Unix, но доступным и на других платформах, реализующих C) является процедура signal, вызываемая в следующей форме:

signal (signal_code, your_routine)

с эффектом вызова обработчика исключения - программы your_routine, когда выполнение текущей программы прерывается, выдавая соответствующий код сигнала (signal_code). Код сигнала - целочисленная константа, например, SIGILL (неверная инструкция - illegal instruction) или SIGFPE (переполнение с плавающей точкой - floating-point exception). В программу можно включить сколь угодно много вызовов процедуры signal, что позволяет обрабатывать различные, возможные ошибки.

Теперь предположим, что при выполнении некоторой инструкции произошло прерывание и выработан соответствующий код сигнала. Будет или нет вызвана процедура signal, но выполнение программы завершается в не нормальном состоянии. Предположим, что вызывается обработчик события - your_routine, пытающийся исправить ситуацию. Беда в том, что, завершив работу, он возвращает управление непосредственно в точку, где произошло прерывание (в не нормальное состояние). Это опасно, вероятнее всего, из этой точки невозможно нормально продолжить работу.

Что необходимо в большинстве подобных случаев - исправить ситуацию и продолжить выполнение, начиная с некоторой особой точки, но не точки прерывания. Мы увидим, что есть простой механизм, реализующий эту схему. Заметьте, он может быть реализован и на C, на большинстве платформ. Достаточно комбинировать процедуру signal с двумя другими библиотечными процедурами: setjmp, вставляющую маркер в точку, допускающую продолжение вычислений, и longjmp для возврата к маркеру. С механизмом setjmp-longjmp следует обращаться весьма аккуратно. Поэтому он не ориентирован на обычных программистов, но может использоваться разработчиками компиляторов для реализации высокоуровневого механизма ОО-исключений, который будет описан в этой лекции.



Какой должна быть степень контроля?


Могут возникнуть замечания по поводу уровня обработки специфических исключений, иллюстрируемых двумя последними примерами. В этой лекции проводилась та точка зрения, что исключение - нежелательное событие; когда оно возникает, то естественная реакция ПО и его разработчика - "я не хочу быть здесь! Выпустите меня отсюда, как можно скорее!". Это, кажется, несовместимым с проведением в предложении rescue глубокого анализа источника исключений.

По этой причине я пытался в моей собственной работе избегать детального разбора случаев причины исключений, стараясь показать, что обработка исключений лишь фиксирует ситуацию, если может, а затем либо fail, либо retry.

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

Принцип Простоты Исключения

Вся обработка, выполняемая в предложении rescue, должна оставаться простой и фокусироваться на единственной цели - возвратить объект получателя в стабильное состояние, допуская повторение, если это возможно.



Как отказаться сразу


Последнее высказывание достойно возведения в ранг принципа.

Принцип отказа

Завершение выполнения предложения rescue, не включающее инструкции retry, приводит к тому, что вызов программы завершается отказом.

Так что, если и были вопросы, как на практике возникает отказ (ситуация (4) в классификации исключений), то это делается именно так, - при завершении предложения rescue.

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

routine is do body end

Тогда, приняв, как временное соглашение, что отсутствие предложения rescue эквивалентно существованию пустого предложения rescue, наша программа эквивалента программе:

routine is do body rescue -- Здесь ничего (пустой список инструкций) end

Из принципа Отказа вытекают следующие следствия: если исключение встретилось в программе, не имеющей предложения rescue, то эта программа вырабатывает отказ, включая исключение у вызывающей программы.

Рассмотрение отсутствующего предложения rescue, как присутствующего пустого предложения, является подходящей аппроксимацией на данном этапе рассмотрения. Но нам придется слегка подправить это правило, когда начнем рассматривать эффект исключений на инвариант класса.



Ключевые концепции


Обработка исключений - это механизм, позволяющий справиться с неожиданными условиями, возникшими в период выполнения.Отказ - это невозможность во время выполнения программы выполнить свой контракт.Программа получает исключение в результате: отказа вызванной ею программы, нарушения утверждений, сигналов аппаратуры или операционной системы об аномалиях, возникших в ходе их работы.Программная система может включать также исключения, спроектированные разработчиком.Программа имеет два способа справиться с исключениями - Повторение вычислений (Retry) и Организованная Паника. При Повторении тело программы выполняется заново. Организованная Паника означает отказ и формирование исключения у вызывающей программы.Формальная роль обработчика исключений, не заканчивающегося retry, состоит в восстановлении инварианта, но не в обеспечении контракта программы. Последнее всегда является делом тела программы (предложения do). Формальная роль ветви, заканчивающейся retry, состоит в восстановлении инварианта и предусловия, так чтобы тело программы могло попытаться в новой попытке выполнить контракт.Базисный механизм обработки исключений, включаемый в язык, должен оставаться простым, если только поощрять прямую цель обработки исключений - Организованную Панику или Повторение. Для приложений, нуждающихся в более тонком контроле над исключениями, доступен класс EXCEPTIONS, позволяющий добраться до свойств каждого вида исключений и провести их обработку. Этот класс позволяет создавать и обрабатывать исключения разработчика.



Когда нет предложения rescue


Формализовав роль предложения rescue, вернемся к рассмотрению ситуации, когда это предложение отсутствует в программе. Правило для этого случая было введено ранее, но с обязательством его уточнения. Ранее полагалось, что отсутствующее предложение rescue эквивалентно присутствию пустого предложения (rescue end). В свете наших формальных правил это не всегда является приемлемым решением. Правило (3) требует:

{True} Rescuer {INV}

Если Rescuer является пустой инструкцией, а инвариант не тождественен True, то правило не выполняется.

Зададим точное правило. Класс Any является корневым классом - прародителем всех классов. В состав этого класса включена процедура default_rescue, наследуемая всеми классами - потомками Any:

default_rescue is -- Обрабатывает исключение, если нет предложения rescue. -- (По умолчанию: ничего не делает) do end

Программа, не имеющая предложения rescue, рассматривается теперь как эквивалентная программе с предложением rescue в следующей форме:

rescue default_rescue

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

Вы, конечно, помните, что одна из ролей процедуры создания состоит в производстве состояния, удовлетворяющего инварианту класса INV. Отсюда понятно, что во многих случаях переопределение default_rescue может основываться на использовании процедур создания.



Корректность предложения rescue


Формальное определение корректности класса выдвигает два требования к компонентам класса. Первое (1) требует, чтобы процедуры создания гарантировали корректную инициализацию - выполнение инварианта класса. Второе (2) напрямую относится к нашему обсуждению, требуя от каждой программы, запущенной при условии выполнения предусловия и инварианта класса, выполнения в завершающем состоянии постусловия и инварианта класса. Диаграмма, описывающая жизненный цикл объекта, отражает эти требования:


Рис. 12.3.  Жизнь объекта

Формально правило (2) говорит:

Для каждой экспортируемой программы r и любого множества правильных аргументов xr

{prer (xr) and INV} Bodyr {postr (xr) and INV}

Для простоты позвольте в дальнейшем рассмотрении игнорировать аргументы xr.

Пусть Rescuer обозначает ту часть предложения rescue, в которой игнорируются все ветви, ведущие к retry, другими словами в этой части сохраняются все ветви, доходящие до конца предложения rescue. Правило (2) задает спецификацию для программ тела - Bodyr. Можно ли получить такую же спецификацию для Rescuer? Она должна иметь вид:

{ ? } Rescuer { ? }

с заменой знаков вопроса соответствующими утверждениями. (Полезно, перед дальнейшим чтением постараться самостоятельно задать эти утверждения.)

Рассмотрим, прежде всего, предусловие для Rescuer. Любая попытка написать нечто не тривиальное будет ошибкой! Напомним, чем сильнее предусловие, тем проще работа программы. Любое предусловие для Rescuer ограничит число случаев, которыми должна управлять эта программа. Но она должна работать во всех ситуациях! Когда возникает исключение, ничего нельзя предполагать, - такова природа исключения. Нам не дано предугадать, когда компьютер даст сбой, или пользователю вздумается нажать клавишу "break".

Поэтому остается единственная возможность - предусловие для Rescuer равно True. Это самое слабое предусловие, удовлетворяющее всем состояниям и означающее, что Rescuer должна работать во всех ситуациях.

Для ленивого создателя Rescuer это "плохая новость", - тот случай, когда "заказчик всегда прав"!

Что можно сказать о постусловии Rescuer? Напомню, эта часть предложения rescue ведет к отказу, но, прежде чем передать управление клиенту, необходимо восстановить стабильное состояние. Это означает необходимость восстановления инварианта класса.

Отсюда следует правило, в котором уже больше нет знаков вопросов:

Правило корректности для включающего отказ предложения rescue

{True} Rescuer {INV}

Похожие рассуждения дают правило для Retryr - части предложения rescue, включающей ветви, приводящие к инструкции retry:

Правило корректности для включающего повтор предложения rescue

{True} Retryr {INV and prer }



Механизм исключений


Из предшествующего анализа следует механизм исключений, наилучшим образом соответствующий ОО-подходу и идеям Проектирования по Контракту.

Для обеспечения основных свойств введем в язык два новых ключевых слова. Для случаев, в которых необходим точно отрегулированный механизм, будет доступен библиотечный класс EXCEPTIONS.



Методологическая перспектива


Финальное замечание и обзор. Обработка исключений, имеющая дело со специальными и нежелательными случаями, - не единственный ответ на общую проблему устойчивости. Мы уже приобрели некоторую методологическую интуицию, но более полный ответ появится в лекции, обсуждающей проектирование интерфейсов модулей, позволяя нам понять место обработки исключений в широком арсенале методов устойчивости и расширения.



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, уведомив клиента, что все завершилось нормально.


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


Теперь у нас есть определение того, что может случиться, - исключения - и того, с чем мы бы не хотели столкнуться в результате появления исключения, - отказа. Давайте разыскивать способы справляться с исключениями так, чтобы не возникли отказы. Что может сделать программа, когда ее выполнение прервано из-за нежелательного поведения?

Помощь в нахождении разумного ответа могут дать примеры того, как не следует поступать в подобных ситуациях. Ими мы обязаны механизму сигналов языка C, пришедшему из Unix, и одному учебнику по языку Ada.



Отказы


Неформально исключение это аномальное событие, прерывающее выполнение программы. Для получения содержательного определения полезно вначале рассмотреть понятие отказа, непосредственно следующее из идеи контракта.

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

Так должно быть, но в жизни не все происходит так, как должно быть. И мы должны ожидать, что рано или поздно при очередном вызове программы она не сможет выполнить свой контракт. Произойдет системное прерывание, или будет вызвана программа в состоянии, не удовлетворяющем ее предусловию, или в заключительном состоянии будет нарушено постусловие либо инвариант (в двух последних случаях предполагается мониторинг утверждений в период выполнения).

Такие ситуации будем называть отказом (failure).

Определения: успех, отказ

Вызов программы успешен, если он завершается в состоянии, удовлетворяющем контракту. Вызов завершается отказом, если он не успешен.

Будем использовать термины "отказ программы" или просто "отказ", как сокращения более точного термина "вызов программы, завершающийся отказом". Понятно, что сама программа не может быть ни успешной, ни давать отказ. Эти понятия применимы только по отношению к конкретному вызову.



Поломки при вводе


Предположим, что в интерактивной системе необходимо выдать подсказку пользователю, от которого требуется ввести целое. Пусть только одна процедура занимается вводом целых - 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



Повторение программы, толерантной к неисправностям


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

Предположим, что редактор, как это обычно бывает для подобных систем, содержит основной цикл, выполняющий команды редактора:

from ... until exit loop execute_one_command end

где тело программы execute_one_command имеет вид:

"Декодировать запрос пользователя" "Выполнить команду, реализующую запрос"

Инструкция "Выполнить ..." выбирает нужную программу (например, удалить строку, заменить слово и так далее). Мы увидим в последующих лекциях, как техника наследования и динамическое связывание дает простые, элегантные структуры для подобных ветвящихся решений.

Будем исходить из того, что не все эти программы являются безопасными. Некоторые из них могут отказать в непредсказуемое время. Вы можете обеспечить примитивную, но эффективную защиту против таких событий, написав программу следующим образом:

execute_one_command is -- Получить запрос от пользователя и, если возможно, -- выполнить соответствующую команду. do "Декодировать запрос пользователя" "Выполнить подходящую команду в ответ на запрос" rescue message ("Извините, эта команда отказала") message ("Пожалуйста, попробуйте использовать другую команду") message ("Пожалуйста, сообщите об отказе автору") "Команды, латающие состояние редактора" retry end

Эта схема предполагает на практике, что поддерживаемые запросы пользователя включают: "сохранить текущее состояние работы", "завершить работу". Оба последних запроса должны работать корректно. Пользователь, получивший сообщение "Извините...", несомненно, захочет сохранить работу и выйти как можно скорее. Некоторые из программ, реализующих команды редактора, могут иметь собственные предложения rescue, хотя и приводящие к отказу, но предварительно выдающие более информативные сообщения.



Примеры обработки исключений


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



Принципы обработки исключений


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

Принципы дисциплинированной обработки исключений

Есть только два легитимных отклика на исключение, возникшее при выполнении программы:

Повторение (Retrying) - попытка изменить условия, приведшие к исключению, и выполнить программу повторно, начиная все сначала.Отказ (Failure) - известный также как "организованная паника" (organized panic): чистка стека и других ресурсов, завершение вызова и отчет об отказе перед вызывающей программой.

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

Давайте начнем рассмотрение с третьего случая - ложной тревоги, обработка которого соответствует основному механизму C-Unix. Вот пример. Некоторые оконные системы будут вызывать исключения, если пользователь перестраивает размеры окна во время выполнения процесса в этом окне. Предположим, что процесс не выполняет никакого вывода в это окно, тогда исключение будет безвредным, и можно возобновить выполнение процесса в прерванной точке. Но даже в этом случае есть лучшие пути, такие как полная блокировка сигналов на время выполнения процесса, чтобы исключение вообще не встретилось. Именно так мы будем поступать с ложными тревогами в механизме, рассматриваемом в следующем разделе.

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

Повторение - более обнадеживающая стратегия: мы потерпели поражение в битве, но не проиграли войну. Хотя наш первоначальный план выполнения контракта потерпел неудачу, мы можем постараться удовлетворить клиента, применив другую тактику.
Если она будет успешной, то исключение не оказывает никакого влияния на клиента. После одной или нескольких попыток, приведших к неудаче, в очередной попытке нам, возможно, удастся полностью выполнить контракт ("Миссия завершена, сэр. Обычные, небольшие проблемы, сэр. Теперь все хорошо, сэр").

Что значит "другая тактика", испытываемая при следующей попытке? Это может быть другой алгоритм; или тот же алгоритм, выполняемый после некоторых произведенных изменений в начальном состоянии (атрибуты, локальные переменные). В некоторых случаях это может быть просто повторный запуск той же программы в надежде, что изменились внешние условия - освободились временно занятые устройства, линии связи и так далее.

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

Обеспечить появление исключения у вызывающей программы. В этом и состоит аспект "паники" - программа отказывается жить в соответствии с ее контрактом.Восстановить согласованное состояние выполнения - "организованный" аспект.

Что является согласованным состоянием? Корректность класса позволяет дать ответ: состояние, удовлетворяющее инварианту. Мы уже говорили, что программа во время ее выполнения может нарушать инвариант, восстанавливая его в конце работы. Если возникло исключение, то инвариант может быть нарушен. Программа должна восстановить его до возвращения управления вызывающей программе.


Продвинутая обработка исключений


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

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

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

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



Ситуации отказа


Рассматривая список возможных исключений, полезно определить, когда может встретиться отказ (причина исключения у вызывающей программы):

Определение: случаи отказа

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

Определения отказа и исключения взаимно рекурсивны: отказ возникает из-за появления исключений, а одна из причин исключения - отказ при вызове программы (случай (4)).



Спаси и Повтори (Rescue и Retry)


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

routine is require precondition local ... Объявление локальных сущностей ... do body ensure postcondition rescue rescue_clause end

Предложение rescue_clause является последовательностью инструкций. При возникновении исключения в теле программы вычисление прерывается, и управление передается предложению rescue. Хотя есть только одно такое предложение на программу, но в нем можно проанализировать причину исключения и нужным образом реагировать на различные события.

Другой новой конструкцией является инструкция retry, записываемая просто как retry. Эта инструкция может появляться только в предложении rescue. Ее выполнение состоит в том, что она повторно запускает тело программы с самого начала. Инициализация, конечно, не повторяется.

Эти конструкции являются прямой реализацией принципа Дисциплинированной Обработки Исключений. Инструкция retry обеспечивает механизм повторения; предложение rescue, не заканчивающееся retry приводит к отказу.



Таблица истории исключений


Если в программе произошел отказ, то ли из-за отсутствия предложения rescue, то ли потому, что это предложение закончилось без retry, она прервет выполнение вызывающей программы, вызвав в ней исключение типа (4) - отказ в вызываемой программе. Вызывающая программа столкнется с теми же самыми двумя возможностями: либо в ней есть предложение rescue, способное исправить ситуацию, либо она выработает отказ и передаст управление вверх по цепочке вызовов. Если на всем пути не найдется программы, способной справиться с исключением, то выполнение всей системы закончится отказом. В этом случае окружение должно сформировать и вывести ясную картину произошедшего - таблицу истории исключения. Вот пример такой таблицы:

Таблица 12.1. Пример таблицы истории исключений

ОбъектКлассПрограммаПрирода исключенияЭффект
O4Z_Functionsplit (from E_FUNCTION)Feature interpolate: Вызывалаcь ссылкой voidПовторение

O3

O2

O2

INTERVAL

EQUATION

EQUATION

integrate

solve (from GENERAL_EQUATION)

filter

interval_big_enough: Нарушено предусловие

Отказ программы

Отказ программы

Отказ

Отказ

Повторение

O2

O1(root)

MATH

INTERFACE

new_matrix (from BASIC_MATH)

make

enough_memory: Check Нарушение

Отказ программы

Отказ

Отказ

Эта таблица содержит историю не только тех исключений, которые привели, в конечном счете, к отказу системы, но и исключений, эффект которых был преодолен в результате выполнения rescue - retry. Число исключений в таблице может быть ограничено, например, числом 100 по умолчанию. Порядок в таблице сверху вниз является обратным порядку, в котором вызываются программы. Корневая процедура создания записана в последней строке таблицы.

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


Столбец Природа Исключения указывает, что случилось. Здесь, как показано во второй сверху строке таблицы, могут использоваться метки утверждений, например, interval_big_enough, что позволяет точно идентифицировать нарушаемое предложение в программе.
Последний столбец указывает, как обрабатывалось исключение, то ли используя Повторение, то ли Отказ. Таблица состоит из последовательности секций, отделяемых толстой линией. Каждая секция, за исключением последней, приводила к Повторению, что указывает на восстановление ситуации. Понятно, что между двумя вызовами, отделенными толстыми линиями, может быть произвольное число вызовов.
Игнорируя такие промежуточные вызовы, - успешные и потому неинтересные для цели нашего обсуждения - здесь приведена цепочка вызовов и возвратов, соответствующая выше приведенной истории исключений. Для реконструкции действий следует следовать по стрелкам, обходя их против часовой стрелки, начиная от программы make, изображенной слева вверху.

Рис. 12.2.  Выполнение, приведшее к отказу

У12.1 Наибольшее целое


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



У12.2 Объект Exception


Несмотря на скептицизм, высказанный в разделе "Обсуждение" этой лекции по поводу рассматривания исключений как объектов, займитесь развитием этой идеи и обсудите, как мог бы выглядеть класс EXCEPTION, полагая, что экземпляры этого класса обозначают исключения, появившиеся при выполнении. Не путайте его с классом EXCEPTIONS, который доступен благодаря наследованию и обеспечивает общие свойства исключений. Попытайтесь, в частности, наряду с запросами, включить команды в разрабатываемый вами класс.

  1)

  Sommerville, Morrison "Software Development with Ada", Addison-Wesley, 1987. Синтаксис и некоторые идентификаторы изменены для приведения в соответствие со стилем данной книги.

Восстановление при исключениях, сгенерированных операционной системой


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

Теоретически можно рассматривать такие условия, как нарушение утверждений. Если a+b приводит к переполнению, то это означает, что вызов не удовлетворяет неявному предусловию функции + для целых или вещественных чисел, устанавливающее, что сумма двух чисел должна быть представима в компьютере. Подобное неявное предусловие задается при создании новых объектов (создание копии) - памяти должно быть достаточно. Отказы встречаются из-за того, что окружение - файлы, устройства, пользователи - не отвечают условиям применимости. Но в таких случаях непрактично или невозможно задавать утверждения, допуская их независимую проверку. Единственное решение - пытаться выполнить операцию, и, если аппаратура или операционная система выдает сигнал о ненормальном состоянии, рассматривать его как исключение.

Рассмотрим проблему написания функции quasi_inverse, возвращающей для каждого вещественного x обратную величину 1/x или 0, если x слишком мало.

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

На некоторых платформах можно написать функцию invertible, такую что invertible(x) равна true, если и только если обратная величина может быть вычислена. Тогда можно написать и quasi_inverse. Но это решение не будет переносимым, и может приводить к потере производительности при интенсивном использовании этой функции.

Механизм 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.



Задача предложения rescue


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



Запросы при работе с классом EXCEPTIONS


Класс EXCEPTIONS обеспечивает несколько запросов для получения требуемой информации о последнем исключении. Прежде всего, можно получить целочисленный код этого исключения:

exception: INTEGER -- Код последнего встретившегося исключения original_exception: INTEGER -- Код последнего исключения - первопричины текущего исключения

Разница между exception и original_exception важна в случае "организованной паники". Если программа получила исключение с кодом oc, указывающим на арифметическое переполнение, но не имеет предложения rescue, то вызывающая программа получит исключение, код которого, заданный значением exception, будет указывать на "отказ в вызванной программе". Но на этом этапе или выше по цепи вызовов может понадобиться выяснить оригинальное исключение - первопричину появления исключений - код oc, который и будет значением original_exception.

Коды исключений являются целыми. Значения для предопределенных исключений задаются целочисленными константами, обеспечиваемыми классом EXCEPTIONS (который наследует их от класса EXCEPTIONS_CONSTANTS). Вот несколько примеров:

Check_instruction: INTEGER is 7 -- Код исключения при нарушении утверждения check Class_invariant: INTEGER is ... -- Код исключения при нарушении инварианта класса Incorrect_inspect_value: INTEGER is ... -- Код исключения, когда проверяемое значение не является ни одной -- ожидаемых констант, если отсутствует часть Else Loop_invariant: INTEGER is ... -- Код исключения при нарушении инварианта цикла Loop_variant: INTEGER is ... -- Код исключения при нарушении убывания варианта цикла No_more_memory: INTEGER is ... -- Код исключения при отказе в распределении памяти Postcondition: INTEGER is ... -- Код исключения при нарушении постусловия Precondition: INTEGER is ... -- Код исключения при нарушении предусловия Routine_failure: INTEGER is ... -- Код исключения при отказе вызванной программы Void_assigned_to_expanded: INTEGER is ...

Так как значения констант не играют здесь роли, то показано только первое из них.


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

meaning (except: INTEGER) -- Сообщение, описывающее природу исключения с кодом except is_assertion_violation: BOOLEAN -- Является ли последнее исключение нарушением утверждения -- или нарушением убывания варианта цикла ensure Result = (exception = Precondition) or (exception = Postcondition) or (exception = Class_invariant) or (exception = Loop_invariant) or (exception = Loop_variant) is_system_exception: BOOLEAN -- Является ли последнее исключение внешним событием -- (ошибкой операционной системы)? is_signal: BOOLEAN -- Является ли последнее исключение сигналом операционной системы? tag_name: STRING -- Метка утверждения, нарушение которого привело к исключению original_tag_name: STRING -- Метка последнего нарушенного утверждения оригинальным исключением. recipient_name: STRING -- Имя программы, чье выполнение было прервано последним исключением class_name: STRING -- Имя класса, включающего получателя последнего исключения original_recipient_name: STRING -- Имя программы, чье выполнение было прервано -- последним оригинальным исключением original_class_name: STRING -- Имя класса, включающего получателя последнего оригинального исключения

Имея эти свойства, предложение rescue может управлять каждым исключением особым способом. Например, в классе, наследуемом от EXCEPTIONS, предложение rescue можно написать так:

rescue if is_assertion_violation then "Случай, обрабатывающий нарушение утверждений" else if is_signal then "Случай, обрабатывающий сигналы операционной системы" else ... end

Используя класс EXCEPTIONS, можно модифицировать пример quasi_inverse, чтобы он выполнял retry только при переполнении. Другие исключения, например, нажатие пользователем клавиши "break" не должны приводить к retry. Инструкция в предложении rescue теперь может иметь вид:

if exception = Numerical_error then division_tried := True; retry end

Так как здесь нет else ветви, то исключения, отличные от Numerical_error, будут причиной отказа - корректное следствие, поскольку программа не имеет рецепта восстановления в подобных случаях. Иногда предложение rescue пишется специально для того, чтобы обработать определенный вид возможных исключений. Этот стиль позволяет избежать анализа других неожиданных видов исключений.


Циклы


Синтаксис циклов описан при обсуждении Проектирования по Контракту (лекция 11):

from initialization_instructions invariant invariant variant variant until exit_condition loop loop_instructions end

Предложения invariant и variant факультативны. Предложение from требуется, хотя и может быть пустым. Оно задает инициализацию параметров цикла. Не рассматривая сейчас факультативные предложения, выполнение цикла можно описать следующим образом. Вначале происходит инициализация, и выполняются initialization_instructions. Затем следует "циклический процесс", определяемый так: если exit_condition верно, то циклический процесс - пустая инструкция (null instruction); если условие неверно, то циклический процесс - это выполнение loop_instructions, затем следует (рекурсивно) повторение циклического процесса.



Инструкции


ОО-нотация, разработанная в этой книге, императивна: вычисления специфицируются через команды (commands), также называемые инструкциями (instructions). (Мы избегаем обычно применимого термина оператор (предложение) (statement), поскольку в слове есть оттенок выражения, описывающего факты, а хотелось подчеркнуть императивный характер команды.)

Для имеющих опыт работы с современными языками инструкции выглядят как хорошие знакомые. Исключение составляют некоторые специальные свойства циклов, облегчающие их верификацию. Вот список инструкций: Вызов процедуры, Присваивание, Условие, Множественный выбор, Цикл, Проверка, Отладка, Повторное выполнение, Попытка присваивания.



Использование внешних программ


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

Открытость остальному миру - требование большинства программных продуктов. Это можно назвать принципом скромности: авторы новых инструментов должны дать возможность пользователям иметь доступ к ранее имевшимся возможностям.

Внешние программы также необходимы для обеспечения доступа к аппаратуре и возможностям операционной системы. Типичный пример - класс файлов. Другой пример - класс ARRAY, чей интерфейс рассматривался в предыдущих лекциях, и чья реализация основана на внешних программах: процедура создания make использует программу распределения памяти, функция доступа item использует внешний механизм для быстрого доступа к элементам массива, и т.д.

Эта техника обеспечивает ясный интерфейс между ОО-миром и другими подходами. Для клиентов внешняя программа - это просто программа. В примере, программа на С _char_write обрела статус компонента (feature) класса, дополнена предусловием и постусловием и получила стандартное имя put. Возможности, внутренне опирающиеся на не ОО-механизмы, получают новую упаковку абстрактных данных, так что участники ОО-мира начинают рассматривать их как законных граждан сообщества, и их низкое происхождение никогда не упоминается в "изысканном обществе". ("Изысканное общество" не означает бесклассовое.)



Ключевые концепции


Внешние программы доступны через хорошо определенный интерфейс.Объектная технология может служить в качестве механизма упаковки наследуемого ПО.Подпрограммы не могут модифицировать свои аргументы, хотя они могут изменять объекты, связанные с этими аргументами.Нотация включает небольшой набор инструкций: присваивания, выбора, цикла, вызова, отладки и проверки.Выражения следуют общепринятому стилю. Current - выражение, обозначающее текущий экземпляр. Не будучи сущностью, Current не может быть целью присваивания.Нестрогие булевы операторы эквивалентны стандартным булевым оператором, когда определены оба операнда, но могут быть определенными в случаях, когда стандартные операторы не определены.Строки, ввод и вывод определяются простыми библиотечными классами.Регистр незначим в идентификаторах, хотя правила стиля включают рекомендуемые соглашения по записи имен.



Лексические соглашения


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

Регистр в идентификаторах не учитывается, так что Hi, hi, HI и hI - все означают один и тот же идентификатор. Было бы опасным позволять двум идентификаторам, различающимся только одним символом, скажем Structure и structure, обозначать различные элементы. Лучше попросить разработчиков включить воображение, чем рисковать возникновением ошибок.

Нотация включает набор точных стандартных соглашений по стилю (см. лекцию 26 курса "Основы объектно-ориентированного проектирования"): имена классов (INTEGER, POINT ...) и формальные родовые параметры (G в LIST [G]) записываются в верхнем регистре; предопределенные сущности и выражения (Result, Current...) и константные атрибуты (Pi) начинаются с буквы верхнего регистра и продолжаются в нижнем регистре. Все другие идентификаторы (неконстантные атрибуты, формальные аргументы программ, локальные сущности) - в нижнем регистре. Хотя компиляторы не проверяют эти соглашения, не являющиеся частью спецификации, они важны для удобочитаемости текстов программных продуктов и последовательно применяются в библиотеках и текстах этой книги.



Манифестные константы


Неименованная или манифестная константа задается значением, синтаксис которого позволяет определить и тип этого значения, например, целое 0. Этим она отличается от символьной константы, чье имя не зависит от значения.

Булевых констант две, - True и False. Целые константы имеют обычную форму, например:

453 -678 +66623

В записи вещественных (real) констант присутствует десятичная точка. Целая, либо дробная часть может отсутствовать. Может присутствовать знак и экспонента, например:

52.5 -54.44 +45.01 .983 -897. 999.e12

Символьные константы состоят из одного символа в одинарных кавычках, например, 'A'. Для цепочек из нескольких символов используется библиотечный класс STRING, описанный ниже.



Множественный выбор


Инструкция множественного выбора (также известная, как инструкция Case) производит разбор вариантов, имеющих форму: e = vi , где e - выражение, а vi - константы то же типа. Хотя условная инструкция (if e = v1 then ...elseif e = v2 then...) работает, есть две причины, оправдывающие применение специальной инструкции, что является исключением из обычного правила: "если нотация дает хороший способ сделать что-то, нет необходимости вводить другой способ". Вот эти причины:

Разбор случаев настолько распространен, что заслуживает особого синтаксиса, увеличивающего ясность, позволяя избежать бесполезного повторения "e =".Компиляторы могут использовать особенно эффективную технику реализации, - таблицу переходов (jump table), - неприменимую к общим условным инструкциям и избегающую явных проверок.

Что касается типа анализируемых величин (тип e и vi), то инструкции множественного выбора достаточно поддерживать только целые и булевы значения. Согласно правилу, они фактически должны объявляться либо все как INTEGER, либо как CHARACTER. Общая форма инструкции такова:

inspect e when v1 then instruction; instruction; ... when v2 then instruction; instruction; ... ... else instruction; instruction; ... end

Все значения vi должны быть различными; часть else факультативна; каждая из ветвей может иметь произвольное число инструкций или не иметь их.

Инструкция действует так: если значение e равно значению vi (это может быть только для одного из них), выполняются инструкции соответствующей ветви; иначе, выполняются инструкции в ветви else, если они есть.

Если отсутствует else, и значение e не соответствует ни одному vi, то возникает исключительная ситуация ("Некорректно проверяемое значение"). Это решение может вызвать удивление, поскольку соответствующая условная инструкция в этом случае ничего не делает. Но оно характеризует специфику инструкции множественного выбора. Когда вы пишете inspect с набором значений vi, нужно включить ветвь else, даже пустую, если вы понимаете, что во время выполнения значения e могут не соответствовать никаким vi.
Если вы не включаете else, то это эквивалентно явному утверждению: "значение e всегда является одним из vi". Проверяя это утверждение и создавая исключительную ситуацию при его нарушении, реализация оказывает нам услугу. Бездействие в данной ситуации - означает ошибку - в любом случае, ее необходимо устранить как можно раньше.

Одно из частых приложений инструкции множественного выбора - анализ символа, введенного пользователем4):

inspect first_input_letter when 'D' then "Удалить строку" when 'I' then "Вставить строку" ... else message ("Неопознанная команда; введите H для получения справки") end

Когда значения vi целые, то они могут быть определены как уникальные (unique values), концепция которых рассмотрена в следующей лекции. Это делает возможным в объявлении определить несколько абстрактных констант, например, Do, Re, Mi, Fa, Sol, La, Si: INTEGER is unique, и затем анализировать их в инструкции: inspect note when Do then...when Re then...end.

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


Нестрогие булевы операторы


Операторы and then и or else (названия заимствованы из языка Ada), а также implies не коммутативны и называются нестрогими (non-strict) булевыми операторами. Их семантика следующая:

Нестрогие булевы операторы

a and then b ложно, если a ложно, иначе имеет значение b.a or else b истинно, если a истинно, иначе имеет значение b.a implies b имеет то же значение, что и: (not a) or else b.

Первые два определения, как может показаться, дают ту же семантику, что и and и or. Но разница выявляется, когда b не определено. В этом случае выражения, использующие стандартные булевы операторы, математически не определены, но данные выше определения дают результат: если a ложно, то a and then b ложно независимо от b; а если a истинно, то a and then b истинно независимо от b. Аналогично, a implies b истинно, если a ложно, даже если b не определено.

Итак, нестрогие операторы могут давать результат, когда стандартные не дают его. Типичный пример:

(i /= 0) and then (j // i = k)

которое, согласно определению, ложно, если i равно 0. Если бы в выражении использовался and, а не and then, то из-за неопределенности второго операнда при i равном 0 статус выражения неясен. Эта неопределенность скажется во время выполнения:

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

Для гарантии интерпретации (2), используйте and then. Аналогично,

(i = 0) or else (j // i /= k)

истинно, если i равно 0, а вариант or может дать ошибку во время выполнения.

Можно недоумевать, почему необходимы два новых оператора - не проще и не надежнее ли просто поддерживать стандарт операторов and и or и принимать, что они означают and then и or else? Это не изменило бы значение булева выражения, когда оба оператора определены, но расширило бы круг случаев, где выражения могут получить непротиворечивое значение.
Именно так некоторые языки программирования, в частности, ALGOL, W и C, интерпретируют булевы операторы. Однако есть теоретические и практические причины сохранять два набора различных операторов.

С точки зрения теории, стандартные математические булевы операторы коммутативны: a and b всегда имеет значение такое же, как b and a, в то время как a and then b может быть определенным, когда b and then a не определено. Когда порядок операндов не имеет значения, предпочтительно использовать коммутативный оператор.С точки зрения практики, некоторые оптимизации компилятора становятся невозможными, если требуется, чтобы компилятор вычислял операнды в заданном выражением порядке, как в случае с некоммутативными операторами. Поэтому лучше использовать стандартные операторы, если известно, что оба операнда определены.

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

b := ((i /= 0) and then (j // i = k))

можно написать

if i = 0 then b := false else b := (j // i = k) end

Нестрогая форма, конечно, проще. Это особенно ясно, когда она используется как условие выхода из цикла:

from i := a.lower invariant -- Для всех элементов из интервала [a.lower .. i - 1], (a @ i) /= x variant a.upper - i until i > a.upper or else (a @ i = x) loop i := i + 1 end; Result := (i <= a.upper)

Цель - сделать Result верным, если и только если значение x находится в массиве a. Использование or здесь будет неверным. В этом случае всегда могут вычисляться два операнда, так что при истинности первого операнда (i > a.upper) произойдет попытка доступа к несуществующему элементу массива a @(aupper+1), что приведет к ошибке во время выполнения (нарушение предусловия при включенной проверке утверждений).

Решение без нестрогих операторов будет неэлегантным.

Другой пример - утверждение, например, инварианта класса, выражающее, что первое значение списка l целых неотрицательно, при условии, что список непустой:

l.empty or else l.first >= 0

При использовании or инвариант был бы некорректен. Здесь нет способа написать условие без нестрогих операторов (кроме написания специальной функции и вызова ее в утверждении). Базовые библиотеки алгоритмов и структур данных содержат много таких случаев.

Оператор implies, описывающий включения, также нестрогий. Форма implies менее привычна, но часто более ясна, например, последний пример выглядит лучше в записи:

(not l.empty) implies (l.first >= 0)


ОО-изменение архитектуры (re-architecturing)


Понятие внешней программы хорошо соответствует остальной части подхода. Основной вклад метода - архитектурный: объектная технология говорит, как разработать структуру систем, чтобы обеспечить расширяемость, надежность и повторное использование. Она также говорит, как заполнить эту структуру. Но что по-настоящему определяет, является ли система объектной, - так это ее модульная организация. Для использования ОО-архитектуры часто разумно использовать прием, называемый обертыванием (wrap), одевая в одежды класса внутренние элементы.

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

Но обычно нет причины заходить так далеко. ОО-нотация адекватна вычислениям любого рода и столь же эффективна, как и вычисления на языках Fortran или C. В каких случаях полезна ОО-инкапсуляция внешнего ПО? Один из них мы видели: обеспечение доступа к операциям, зависящим от платформы. Другой - проблема многих организаций - управление старым ПО, доставшимся в наследство и продолжающим широко использоваться. Объектная технология предлагает возможность обновления таких систем, изменяя их архитектуру, но не переписывая их полностью.

Эта техника, которую можно назвать ОО-перестройкой (object-oriented re-architecturing) дает интересное решение сохранения ценных свойств существующего ПО, готовя его к будущему расширению и эволюции.

Однако для этого необходимы определенные условия:

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


Эти два требования частично сходны, поскольку качество любого ПО в значительной степени определяется качеством его структуры.

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

Библиотека Vision (библиотеки описываются в лекции 14 курса "Основы объектно-ориентированного проектирования") дает переносимую графику и механизмы пользовательского интерфейса, позволяющие разработчикам создавать графические приложения для многих различных платформ с ощущением обычной перекомпиляции. Внутренне, она основана на "родных" механизмах, используемых во внешних программах. Точнее, ее нижний уровень инкапсулирует механизмы соответствующих платформ.Другая библиотека, Math, обеспечивает широкий набор возможностей численных вычислений в таких областях как теория вероятностей, статистика, численное интегрирование, линейные и нелинейные уравнения, дифференциальные уравнения, оптимизация, быстрое преобразование Фурье, анализ временных рядов. Внутренне она основана на коммерческой библиотеке подпрограмм, библиотеке NAG от Nag Ltd., Oxford, но обеспечивает пользователям ОО-интерфейс. Библиотека скрывает используемые ею программы и предлагает абстрактные объекты, понятные математику, физику или экономисту, представленные классами: INTEGRATOR, BASIC_MATRIX, DISCRETE_FUNCTION, EXPONENTIAL_DISTRIBUTION. Прекрасные результаты достигаются благодаря качеству внешних программ - NAG аккумулирует сотни человеко-лет разработки и реализации численных алгоритмов. К нему добавлены ОО-преимущества: классы, скрытие информации, множественное наследование, утверждения, систематическая обработка ошибок через исключительные ситуации, согласованное именование.

Эти примеры типичны для сочетания лучших традиционных программных продуктов и объектной технологии.


Отладка


Инструкция отладки является средством условной компиляции. Она записывается так:

debug instruction; instruction; ... end

В файле управления (Ace-файле) для каждого класса можно включить или отключить параметр debug. При его включении все инструкция отладки данного класса выполняются, при отключении - они не влияют на выполнение.

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



Передача аргументов


Один из аспектов нотации требует разъяснений: что происходит со значениями, переданными в качестве аргументов подпрограмме?

Рассмотрим вызов в форме

r (a1, a2, ..., an)

соответствующий программе

r (x1: T1, x2: T2, ..., xn: Tn) is ...

где r может быть как функцией, так и процедурой, и вызов может быть квалифицированным, как в b.r (...). Выражения a1, a2, ..., an называются фактическими аргументами, а xi - формальными. (Помните, что для родовых параметров типа остается термин "параметр".)

Встают важные вопросы: каково соответствие между фактическими и формальными аргументами? Какие операции допустимы над формальными аргументами? Каково их влияние на соответствующие фактические аргументы?

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

x1 := a1; x2 := a2;... xn := an

Ответ на второй вопрос: внутри тела программы любой формальный аргумент x защищен. Программа не может применять к нему прямых модификаций, таких как:

Присваивание x значения в форме x := ...Процедуры создания, где x является целью: create x.make (...)

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

Ответ на третий вопрос - что может программа делать с фактическими аргументами? - вытекает из того, что присоединение используется для задания семантики связывания формальных и фактических аргументов. Присоединение (см. лекцию 8) означает копирование либо ссылки, либо объекта. Это зависит от того, являются ли соответствующие типы развернутыми:

Для ссылок (обычный случай) при передаче аргументов копируется ссылка, - Void, либо присоединенная к объекту.Для развернутых типов (включающих основные типы INTEGER, REAL и т.п.), при передаче аргументов копируется объект.


В первом случае, запрет операций прямой модификации означает, что нельзя модифицировать ссылку (reference) через повторное присоединение или создание. Но если ссылка не пустая, то разрешается модифицировать присоединенный объект.


Рис. 13.1.  Допустимые операции на аргументе ссылки

Если xi - один из формальных аргументов r, то тело программы может содержать вызов:

xi.p (...)

где p - процедура, применимая к xi, (объявлена в базовом классе типа Ti аргумента xi). Процедура может модифицировать поля объекта, присоединенного к xi во время выполнения, то есть объекта, присоединенного к соответствующему фактическому аргументу ai.

Вызов q (a) никогда не может изменить значение a, если a развернутого типа и является объектом. Если же a является ссылкой, то ссылка не меняется, но объект, присоединенный к ней, может измениться в результате вызова.

Существует много причин, по которым не следует позволять программам прямую модификацию их аргументов. Одна из самых убедительных - Конфликтующие присваивания. Предположим, что язык допускает присваивания аргументам, и процедура1)

dont_I_look_innocuous (a, b: INTEGER) is -- я выгляжу -- безвредной, но не стоит мне доверять. do a := 0; b := 1 end

Теперь рассмотрим вызов dont_I_look_innocuous (x, x). Каково значение x после возвращения: 0 или 1? Ответ зависит от того, как компилятор реализует изменения формальных - фактических аргументов при выходе программы. Это ставит в тупик не только программистов, использующих язык Fortran.

Разрешение программе изменять аргументы приводит к ограничениям на фактические аргументы. В этом случае он должен быть элементом, способным изменять свое значение, что допустимо для переменных, но не постоянных атрибутов (см. лекцию 18). Недопустимым фактическим аргументом становится сущность Current, выражения, такие как a + b. Устранение модификации аргументов позволяет избежать подобных ограничений и использовать любые выражения в качестве фактических аргументов.

Следствием этих правил является признание того, что только три способа допускают модификацию значения ссылки x: процедура создания create x...; присваивание x := y; и попытка присваивания x ?= y, обсуждаемая ниже.Передача x как фактического аргумента никогда не модифицирует x.

Это также означает, что процедура не возвращает ни одного результата, функция - официальный результат, представленный сущностью Result. Для получения нескольких результатов необходимо одно из двух:

Использовать функцию, возвращающую объект с несколькими полями (обычно, возвращается ссылка на такой объект).Использовать процедуру, изменяющую поля объектов соответствующих атрибутов. Затем клиент может выполнять запросы к этим полям.

Первый прием уместен, когда речь идет о составном результате. Например, функция не может возвращать два значения, соответствующих заглавию и году публикации книги, но может возвращать одно значение типа BOOK, с атрибутами title и publication_year. В более общих ситуациях применяются процедуры. Эта техника будет обсуждаться вместе с вопросом побочных эффектов в разделе принципов модульного проектирования2).


Повторение вычислений


Инструкция повторного выполнения рассматривалась при обсуждении исключительных ситуаций (лекция 12). Она появляется только в предложении rescue, повторно запуская тело подпрограммы, работа которой была прервана.



Присваивание (Assignment)


Инструкция присваивания записывается в виде:

x := e

где x - сущность, допускающая запись (writable), а e - выражение совместимого типа. Такая сущность может быть:

неконстантным атрибутом включающего класса;локальной сущностью включающей подпрограммы. Для функции допустима сущность Result.

Сущности, не допускающие запись, включают константные атрибуты и формальные аргументы программы - которым, как мы видели, подпрограмма не может присваивать новое значение.



Проверка


Инструкция проверки рассматривалась при обсуждении утверждений (лекция 11). Она говорит, что определенные утверждения должны удовлетворяться в определенных точках:

check assertion -- Одно или больше предложений end



Создание (Creation)


Инструкция создания изучалась в предыдущих лекциях3) в двух ее формах: без процедуры создания, как в create x, и с процедурой создания, как в create x.p (...). В обоих случаях x должна быть сущностью, допускающей запись.



Строки


Класс STRING описывает символьные строки. Он имеет специальный статус, поскольку нотация допускает манифестные строковые константы, обозначающие экземпляры STRING.

Строковая константа записывается в двойных кавычках, например,

"ABcd Ef ~*_ 01"

Символ двойных кавычек должны предваряться знаком %, если он появляется как один из символов строки.

Неконстантные строки также являются экземплярами класса STRING, чья процедура создания make принимает в качестве аргумента ожидаемую начальную длину строки, так что

text1, text2: STRING; n: INTEGER; ... create text1.make (n)

динамически размещает строку text1, резервируя пространство для n символов. Заметим, что n - только исходный размер, не максимальный. Любая строка может увеличиваться и сжиматься до произвольного размера.

На экземплярах STRING доступны многочисленные операции: сцепление, выделение символов и подстрок, сравнение и т.д. (Они могут изменять размер строки, автоматически запуская повторное размещение, если размер строки становится больше текущего.)

Присваивание строк означает разделение (sharing): после text2 := text1, любая модификация text1 модифицирует text2, и наоборот. Для получения копии строки, а не копии ссылки, используется клонирование text2 := clone (text1).

Константную строку можно объявить как атрибут:

message: STRING is "Your message here"



Текущий объект


Зарезервированное слово Current означает текущий экземпляр класса и может использоваться в выражении. Само Current - тоже выражение, а не сущность, допускающая запись. Значит присваивание Current, например, Current := some_value будет синтаксически неверным.

При ссылке на компонент (атрибут или программу) текущего экземпляра нет необходимости писать Current.f, достаточно написать f. Поэтому Current используется реже, чем в ОО-языках, где каждая ссылка на компонент должна быть явно квалифицированной. (Например, в Smalltalk компонент всегда квалифицирован, даже когда он применим к текущему экземпляру.) Случаи, когда надо явно называть Current включают:

Передачу текущего экземпляра в качестве аргумента в программу, как в a.f (Current). Обычное применение - создание копии (duplicate) текущего экземпляра, как в x: = clone (Current).Проверку,- присоединена ли ссылка к текущему экземпляру, как в проверке x = Current.Использование Current в качестве опорного элемента в "закрепленном объявлении" в форме like Current (лекция 16).



У13.1 Внешние классы


При обсуждении интеграции внешнего не объектного ПО с объектной системой отмечалось, что компоненты являются тем уровнем, на котором нужно осуществлять интеграцию. Когда же речь идет об интеграции с ПО, созданным на другом объектном языке, уровнем интеграции могут быть классы. Рассмотрите понятие "внешнего класса" как дополнение к нотации книги.



У13.2 Избегая нестрогих операторов


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

  1)

  ПРЕДУПРЕЖДЕНИЕ: некорректный текст программы. Только для целей иллюстрации.

  2)

  См. лекцию 5 курса "Основы объектно-ориентированного проектирования", особенно "Схема, основанная на опыте".

  3)

  См. "Инструкция создания" и "Процедуры создания", лекцию 8. Один из вариантов рассмотрен в "Полиморфное создание", лекция 14.

  4)

  Это элементарная схема. О более сложных технических приемах обработки пользовательских команд см. лекцию 3 курса "Основы объектно-ориентированного проектирования".

Улучшенные варианты


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

Некоторые внешние программные элементы могут быть макросами. Они имеют вид подпрограмм в ОО-мире, но любой их вызов предполагает вставку тела макроса в точке вызова. Этого можно достичь вариацией имени языка (как, например, "C:[macro]...").Необходимо также разрешить вызовы программ из "динамически присоединяемых библиотек" (DLL), доступных в Windows и других платформах. Программа DLL загружается динамически во время первого вызова. Имя программы и библиотеки разрешается также задавать динамически в период выполнения. Поддержка DLL должна включать как способ статической спецификации имени, так и полностью динамический подход с использованием библиотечных классов DYNAMIC_LIBRARY и DYNAMIC_ROUTINE. Эти классы можно инициализировать во время выполнения, создавая объекты, представляющие динамически определенные библиотеки и подпрограммы.Необходима и связь в обратном направлении, позволяющая не объектному ПО создавать объекты и вызывать компоненты. Например, графической системе может понадобиться механизм обратного вызова (callback mechanism), вызывающий определенные компоненты класса.

Все эти возможности присутствуют в ОО-среде, описанной в последней лекции. Однако их подробное обсуждение - это отдельный разговор.



Условная Инструкция (Conditional)


Эта инструкция задает различные формы обработки в зависимости от выполнения определенных условий. Основная форма:

if boolean_expression then instruction; instruction; ... else instruction; instruction; ... end

где каждая ветвь может иметь произвольное число инструкций (а возможно и не иметь их).

Будут выполняться инструкции первой ветви, если boolean_expression верно, а иначе - второй ветви. Можно опустить часть else, если второй список инструкций пуст, что дает:

if boolean_expression then instruction; instruction; ... end

Когда есть более двух возможных случаев, можно избежать вложения (nesting) условных команд в частях else, используя одну или более ветвей elseif, как в:

if c1 then instruction; instruction; ... elseif c2 then instruction; instruction; ... elseif c3 then instruction; instruction; ... ... else instruction; instruction; ... end

где часть else остается факультативной. Это дает возможность избежать вложения

if c1 then instruction; instruction; ... else if c2 then instruction; instruction; ... else if c3 then instruction; instruction; ... ... else instruction; instruction; ... end end end

Когда необходим множественный разбор случаев, более удобна инструкция множественного выбора inspect, обсуждаемая ниже.

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



Внешние программы


ОО-системы состоят из классов, образованных компонентами (features), в частности, подпрограммами, содержащими инструкции. Что же является правильным уровнем модульности (granularity) для интегрирования внешнего программного продукта?

Конструкция должна быть общей - это исключает классы, существующие только в ОО-языках. Инструкции - слишком низкий уровень. Последовательность, в которой две ОО-инструкции окаймляют инструкцию на языке С:

-- только в целях иллюстрации create x l make (clone (a)) (struct A) *x = &y; /* A piece of C */ x.display

трудно было бы понять, проверить, сопровождать.

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

Отсюда вытекает понятие внешней программы. Внешняя программа имеет большинство признаков нормальной программы: имя, список аргументов, тип результата, если это функция, предусловие и постусловие, если они уместны. Вместо предложения do она имеет предложение external, определяющее язык реализации. Следующий пример взят из класса, описывающего символьные файлы:

put (c: CHARACTER) is -- Добавить c в конец файла. require write_open: open_for_write external "C" alias "_char_write"; ensure one_more: count = old count + 1 end

Предложение alias факультативно и используется, только если оригинальное имя внешней программы отличается от имени, данного в классе. Это случается, когда внешнее имя недопустимо в ОО-нотации, например, имя, начинающееся с символа подчеркивания (используемое в языке С).



Вопрос совместимости: гибридный программный продукт или гибридные языки?


Теоретически, мало кто не согласится с принципом скромности или будет отрицать необходимость механизма интеграции между ОО-разработками и старым ПО. Противоречия возникают, когда выбирается уровень интеграции.

Многие языки - самыми известными являются Objective-C, C++, Java, Object Pascal и Ada 95 - пошли по пути добавления ОО-конструкций в существовавший не ОО-язык. Они известны как гибридные языки (hybrid languages) - см. лекцию 17 курса "Основы объектно-ориентированного проектирования".

Техника интеграции, описанная выше, основывалась на внешних программах и ОО-перестройке. Это другой принцип: необходимость в совместимости ПО не означает перегрузку языка механизмами, могущими расходиться с принципами объектной технологии.

Гибрид добавляет новый языковой уровень к существующему языку, например С. В результате сложность может ограничить привлекательность объектной технологии - простоту идей.Начинающие часто с трудом осваивают гибридный язык, поскольку для них неясно, что именно является ОО, а что досталось из прошлого.Старые механизмы могут быть несовместимыми, по крайней мере, с некоторыми аспектами ОО-идей. Есть много примеров несоответствий между системой типов языков С или Pascal и ОО-подходом.Не объектные механизмы часто конкурируют со своими аналогами. Например, C++ предлагает, наряду с динамическим связыванием, возможность динамического выбора, используя аппарат указателей функций. Это смущает неспециалиста, не понимающего, какой подход выбрать в данном случае. В результате, программный продукт, хотя и создан ОО-средой, по сути является реализацией на языке С, и не дает ожидаемого качества и производительности, дискредитируя объектную технологию.

Если целью является получение наилучших программных продуктов и процесса их разработки, то компромисс на уровне языка кажется неправильным подходом. Взаимодействие (Interfacing) ОО-инструментария и приемов с достижениями прошлого и смешивание (mixing) различных уровней технологии - не одно и то же.

Можно привести пример из электроники. Конечно, полезно сочетать различные уровни технологии в одной системе, например, звуковой усилитель включает несколько диодов наряду с транзисторами и интегральными схемами. Но мало проку от компонента, который является полудиодом, полутранзистором.

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



Ввод и вывод


Два класса библиотеки KERNEL обеспечивают основные средства ввода и вывода: FILE и STD_FILES.

Среди операций, определенных для объекта f типа FILE, есть следующие:

create f.make ("name") -- Связывает f с файлом по имени name.

f.open_write -- Открытие f для записи

f.open_read -- Открытие f для чтения

f.put_string ("A_STRING") -- Запись данной строки в файл f

Операции ввода-вывода стандартных файлов ввода, вывода и ошибок, можно наследовать из класса STD_FILES, определяющего компоненты input, output и error. В качестве альтернативы можно использовать предопределенное значение io, как в io.put_string ("ABC"), обходя наследование.



Выражения


Выражение задает вычисление, вырабатывающее значение, - объект или ссылку на объект. Выражениями являются:

неименованные (манифестные) константы;сущности (атрибуты, локальные сущности, формальные аргументы, Result);вызовы функций;выражения с операторами (технически - это специальный случай вызова функций);Current.



Выражения с операторами


Выражения могут включать знаки операций или операторы.

Унарные операторы + и - применяются к целым и вещественным выражениям и не применяются к булевым выражениям.

Бинарные операторы, имеющие точно два операнда, включают операторы отношения:

= /= < > <= >=

где /= означает "не равно". Значение отношения имеет булев тип.

Выражения могут включать один или несколько операндов, соединенных операторами. Численные операнды могут соединяться следующими операторами:

+ - . / ^ // \\

где // целочисленное деление, \\ целый остаток, а ^ степень (возведение в степень).

Булевы операнды могут соединяться операторами: and, or, xor, and then, or else, implies. Последние три объясняются в следующем разделе; xor - исключающее или.

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



Вызов процедуры


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

Вызов может быть квалифицированным или неквалифицированным. Для неквалифицированного вызова подпрограммы из включающего класса в качестве цели используется текущий экземпляр класса. Этот вызов имеет вид:

r (без аргументов), или

r (x, y, ...) (с аргументами)

Квалифицированный вызов явно называет свою цель, заданную некоторым выражением. Если a - выражение некоторого типа, C - базовый класс этого типа, а - q одна из программ C, то квалифицированный вызов имеет форму a.q. Опять же, за q может следовать список фактических аргументов; a может быть неквалифицированным вызовом функции с аргументами, как в p (m).q (n), где p(m) - это цель. В качестве цели можно также использовать более сложное выражение при условии заключения его в скобки, как в (vector1 + vector2).count.

Также разрешаются квалифицированные вызовы с многоточием в форме: a.q1q2 ...qn, где a, так же, как и qi , может включать список фактических аргументов.

Экспорт управляет применением квалифицированных вызовов. Напомним, что компонент f, объявленный в классе B, доступен в классе A (экспортирован классу), если предложение feature, объявляющее f, начинается с feature (без дальнейшего уточнения) или feature {X, Y,... }, где один из элементов списка {X, Y,...} является A или предком A. Имеет место:

Правило Квалифицированного Вызова

Квалифицированный вызов вида b.q1. q2.... qn, появляющийся в классе C корректен, только если он удовлетворяет следующим условиям:

Компонент, стоящий после первой точки, q1, должно быть доступен в классе C.В вызове с многоточием, каждый компонент после второй точки, то есть, каждое qi для i > 1, должен быть доступен в классе C.

Чтобы понять причину существования второго правила, отметим, что a.q.r.s - краткая запись для

b:= a.q; c:=b.r; c.s

которая верна только, если q, r и s доступны классу C, в котором появляется этот фрагмент. Не имеет значения, доступно ли r базовому классу типа q, и доступно ли s базовому классу типа r.

Вызовы могут иметь инфиксную или префиксную форму. Выражение a + b, записанное в инфиксной форме, может быть переписано в префиксной форме: a.plus (b). Для обеих форм действуют одинаковые правила применимости.



Вызовы функций


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

b.f b.g(x, y, ...) b.h(u, v).i.j(x, y, ...)

Правило квалифицированного вызова, приведенное для процедур, применимо также к вызовам функций.



Взаимодействие с не объектным ПО


До сих пор, элементы ПО выражались полностью в ОО-нотации. Но программы появились задолго до распространения ОО-технологии. Часто возникает необходимость соединить объектное ПО с элементами, написанными, например, на языках С, Fortran или Pascal. Нотация должна поддерживать этот процесс.

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