Опубликован: 23.10.2005 | Доступ: свободный | Студентов: 4086 / 201 | Оценка: 4.44 / 4.19 | Длительность: 33:04:00
Специальности: Программист
Лекция 5:

Принципы проектирования класса

Выборочный экспорт

Отношение между классами LINKABLE и LINKED_LIST иллюстрируют важность поддержки у компонента более двух статусов экспорта - открытого (общедоступного) и закрытого (секретного).

Класс LINKABLE не должен делать свои компоненты - item, right, make, put, put_right - общедоступными, так как большинство клиентов не должно влезать во внутренности звеньев, они должны использовать только связные списки. Но их нельзя делать секретными, поскольку это спрятало бы их от LINKED_LIST, для которого они и предназначены, так как вызовы active.right, основа операций forth и других подпрограмм LINKED_LIST, были бы тогда невозможны.

Выборочный экспорт обеспечивает решение, позволяя LINKABLE отбирать то множество классов, которому и только которому экспортируются эти компоненты:

class
    LINKABLE [G]
feature {LINKED_LIST}
    item: G
    right: LINKABLE [G]
    -- и т.д.
end

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

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

(left /= Void) implies (left.right = Current)

Это требует, чтобы right было объявлено в предложении feature {... Другие классы ..., BI_LINKABLE} ; в противном случае вызов left.right будет неверным.

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

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

Как справляться с особыми ситуациями

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

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

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

if some_abnormal_situation_detected then
    raise some_exception;
end;
"Далее - нормальная обработка"

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

Мы видели в лекции 12 курса "Основы объектно-ориентированного программирования", что механизм дисциплинированных исключений полностью соответствует ОО-подходу, в частности согласуется с Принципом Проектирования по Контракту. Но не во всех специальных случаях обоснованно обращаться к исключениям. Техника проектирования, которой мы сейчас займемся, на первый взгляд, покажется менее выразительной, может характеризоваться как "техника низкого уровня" ("low-tech"). Но она чрезвычайно мощная и подходит ко многим возможным практическим ситуациям. После ее изучения дадим обзор тех ситуаций, где использование исключений остается непременным.

Априорная схема

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

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

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

operation (x:...) is
    require
        precondition (x)
    do
    ... Код, работающий только при условии выполнения предусловия...
    end

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

if precondition (y) then
    operation (y)
else
    ... Подходящие альтернативные действия...
end

(Для краткости этот пример использует неквалифицированный вызов, но, конечно же, большинство вызовов будут квалифицированными в форме: z.operation (y).) Чтобы избежать теста if...then...else, следует убедиться, что из контекста следует выполнение предусловия:

...Некоторые инструкции, которые, среди прочего, гарантируют выполнение предусловия...
        check precondition (y) end
operation (y)

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

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

Препятствия на пути априорной схемы

Из-за простоты и ясности априорная схема, в принципе, идеальна. По трем причинам она не является универсально применимой:

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

Примером случая А1 является решатель линейных уравнений. Функция, дающая решение системы линейных уравнений в форме a x = b, где a - матрица, а x и b - векторы, может быть взята из соответствующего библиотечного класса MATRIX:

inverse (b: VECTOR): VECTOR

Решение системы находится так: x := a.inverse(b). Единственное решение системы существует, только если матрица не "сингулярна". (Сингулярность здесь означает линейную зависимость уравнений, признаком которой является равенство 0 определителя матрицы.) Можно было бы ввести проверку на сингулярность в предусловие inverse, требуя, чтобы вызовы клиента имели вид:

if a.singular then
    ...Подходящие действия для сингулярной матрицы...
else
    x := a.inverse (b)
end

Эта техника работает, но она неэффективна, поскольку определение сингулярности, по сути, дается тем же алгоритмом, что и нахождение решения системы. Так что одну и ту же работу придется выполнять дважды - сплошное расточительство.

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

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

Апостериорная схема

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

Примером может служить по-прежнему решение системы линейных уравнений. При апостериорной схеме клиентский код может выглядеть так:

a.invert (b)
if a.inverted then
    x := a.inverse
else
    ... Подходящие действия для сингулярной матрицы...
end

Функция inverse заменена процедурой invert, более аккуратным именем которой было бы attempt_to_invert. При вызове процедуры вычисляется атрибут inverted, истинный или ложный в зависимости от того, найдено ли решение. В случае успеха решение становится доступным через атрибут inverse. (Инвариант класса может быть задан в виде: inverted = (inverse /= Void).)

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

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

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

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

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

Роль механизма исключений

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

Остаются, однако, ситуации, когда обе схемы не являются адекватными. Возможны три таких категории:

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

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