Опубликован: 04.04.2012 | Доступ: свободный | Студентов: 1988 / 60 | Оценка: 4.60 / 4.40 | Длительность: 13:49:00
Лекция 3:

Проектирование семейства классов

< Лекция 2 || Лекция 3: 123 || Лекция 4 >

Определение: переобъявление (redeclaration)
Переобъявление наследуемого компонента означает изменение чего-либо или всего - сигнатуры, контракта, реализации, а также удаление реализации. Варианты включают задание реализации ( effecting ), переопределение ( redefinition ), отмену реализации ( undefinition ).

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

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

 Переопределение

Рис. 2.3. Переопределение

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

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

class
  DISPATCH_TAXI
inherit
  TAXI redefine take end
feature
  take (from_location,to_location: LOCATION)
    — Перевезти пассажиров из from_location в to_location
    do
     ... Новая реализация...
    end
 ... Другие компоненты и оставшаяся часть класса...
end

Если класс переопределяет несколько компонентов, он перечисляет все: redifinef, g,...end Цель предложения redefine — это ясность и безопасность. Важное правило надежного ОО-программирования состоит в том, что имя компонента в классе не должно использоваться для именования двух различных методов. Эта ситуация, известная как перегрузка ( overloading ), допускается в некоторых языках программирования, что увеличивает риск непонимания. Предложение redefine поясняет клиенту класса, читающему текст, отношение с компонентом родителя, имеющим совпадающее имя: это не новый компонент с тем же именем, а переопределение компонента.

Если пропустить предложение, возникнет ошибка на этапе компиляции, так как класс полагает, что в нем определены два метода с одним именем (перегрузка), что запрещено правилами Eiffel.

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

Переопределение, подобно эффективизации, использует динамическое связывание:

group_move (taxi_ fleet: LIST [TAXI]— Смотри далее о классе TARGET
    — Заставляет все такси в taxi_fleet следовать одним маршрутом.
  do
    from taxi_ fleet.start until taxi_ fleet.after loop
      taxi_ fleet.item.take(...) taxi_ fleet.forth
    end
  end

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

Если вы переопределяете метод, родительская версия известна как предшественник ( precursor ) метода. Довольно часто новая реализация основывается на версии предшественника. Вместо простого дублирования кода (разве я не упоминал, что copy-paste — не лучшая идея?) можно использовать ключевое слово Precursor. Новая реализация take в DISPATCHTAXI выглядит следующим образом:

take (from_location,to_location: LOCATION)
    — Перевезти пассажиров из from_location в to_location
  do
    Precursorffrom_location, to_location)
   ... Другие операции, которые характерны для такси, контролируемых диспетчером...
  end

Это означает, что вначале выполняется версия, предусмотренная предшественником, а затем добавляются операции, учитывающие специфику.

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

За пределами скрытия информации

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

my_vehicle.load (...)

вы запрашиваете некоторую абстрактную операцию load, применяемую к целевому объекту, но так как вам не известно (переменная myvehicle, возможно, полиморфна), какой точно тип имеет связанный с ней объект, — динамическое связывание означает, что вы не знаете, какой именно метод будет вызван.

В этом причина важности контрактов. То, что вы должны знать, заключено в исходном контракте load, включенном в отложенный класс VEHICLE. Контракт описывает семантику load — загрузку n пассажиров в транспортное средство. Это справедливо для всех вариантов, хотя каждый из них отличается деталями реализации.

Выбор из многих вариантов

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

load (v: VEHICLE; n: INTEGER)
     — Загрузить n пассажиров в v.
   do
     if "v объект tram" then
       "Применить алгоритм посадки в трамвай"
     elseif "v объект taxi" then
       "Проверить, что число пассажиров не более 4-х"
       "Применить алгоритм посадки в такси"
      elseif...
     end
   end

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

  • Когда вариантов много, приходится строить длинный, а в результате сложный оператор if (применение конструкции с разбором случаев в таких ситуациях гораздо предпочтительнее).
  • Приходится повторять вызов каждой операции, такой как load, которая концептуально применима к любому транспортному средству, хотя выполняется по-разному. Таких операций может быть много.

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

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

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

Почувствуй методологию

Сражение с синдромом "Много явных вариантов"

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

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

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

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

Беглый взгляд на реализацию

В целом эта книга представляет концепции программирования с позиций их использования прикладными программистами (но не разработчиками компиляторов — тема замечательная,

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

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

cab_at_corner.load (...)

должна применяться версия метода load класса TAXI, что выводимо из типа, заданного при объявлении цели cab_at_corner. Для описания генерируемого кода позвольте использовать язык С. Он является языком достаточно низкого уровня и может служить представителем ассемблерных языков, но все же он достаточно высокого уровня, чтобы оставаться понимаемым и не зависимым от конкретной платформы (помимо всего, компилятор EiffelStudio в качестве одного из своих возможных выходов генерирует код на языке С, так что рассматриваемая ситуация реалистична).

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

C_TAXI_load (C_cab..,.);
Листинг 2.1.

Здесь C_TAXI_load является результатом трансляции вызова метода load для версии класса TAXI. Так как С не является ОО-языком и не имеет понятия квалифицированного вызова x./(...), функции в языке С имеют, по крайней мере, один аргумент, соответствующий цели, — здесь С_ cab представляет исходный cab_at_corner в Eiffel.

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

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

v.load (. )

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

 Разрешение динамически связываемого вызова

Рис. 2.4. Разрешение динамически связываемого вызова

Ключевой структурой является таблица методов2 При реализации языков, подобных С++, C#, где динамическое связывание применяется к методам, объявленным как виртуальные, такая таблица называется таблицей виртуальных методов(называемая также таблицей переходов). Входами в этой таблице являются ссылки. В отличие от обычных ссылок они указывают не на данные, а на код — в данном примере на соответствующую версию метода load.

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

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

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

Если рассматривать эти свойства с позиций разработчика компилятора, то приходим к важному заключению — каждый объект должен содержать идентификацию своего собственного типа. В противном случае не было бы возможности добиться динамического связывания, так как именно тип определяет, какой вариант метода следует выбрать из множества возможных. Реализации ОО-языков действительно включают в представление любого объекта в дополнение к его полям, задающим атрибуты, поле, задающее тип (на рисунке оно помечено как "Тип"). Обычно тип задается целым числом и для него отводится одно слово памяти (4 или 8 байтов), этого достаточно в современных прикладных системах для задания любого возможного типа. Реализация EiffelStu dio добавляет все же еще одно слово к каждому объекту для контроля информации, необходимой, в частности, сборщику мусора. Таковы издержки ОО-механизмов в этой реализации — два дополнительных слова на каждый объект. В целом это приемлемая плата, но могут возникать проблемы, если создается очень много небольших объектов.

Можно пройти по пути, показанному на рисунке, начав с объекта v слева вверху, следуя стрелкам. Если значение v не равно void, то ссылка приведет нас к объекту. Поле "Тип" этого объекта даст нам соответствующее целое, представляющее тип объекта (здесь TAXI). Мы используем это целое для индексации входа в таблицу методов, построенную для load ; соответствующий вход даст адрес программного кода соответствующего варианта метода.

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

(routine_table[(_v).type]) (v,...);
Листинг 2.2.

Пояснение : символ * означает операцию разыменования, так что * v — это объект, заданный ссылкой v, тогда (*v).type (что может быть также записано как v-> type ) является соответствующим полем объекта и его значение используется как индекс в массиве routine_table. Так мы получаем адрес нужной C-программы, которой и передаются необходимые ей аргументы. Как и ранее, список аргументов включает помимо исходных аргументов и аргумент, задающий целевой объект, известный здесь как v, играющий роль объекта C_cab.

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

 Общая схема реализации динамического связывания

Рис. 2.5. Общая схема реализации динамического связывания

Все эти решения удовлетворяет важному требованию.

< Лекция 2 || Лекция 3: 123 || Лекция 4 >
Надежда Александрова
Надежда Александрова

Уточните пожалуйста, какие документы для этого необходимо предоставить с моей стороны. Курс "Объектно-ориентированное программирование и программная инженения".