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

Используйте наследование правильно

Один механизм, или несколько?

Заметьте, это обсуждение предполагает в качестве основы раннюю презентацию, определяющую смысл наследования (см. лекцию 14 курса "Основы объектно-ориентированного программирования").

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

Такое разделение нанесло бы больше вреда, чем принесло пользы. Вот несколько доводов.

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

Практическим следствием были бы бесполезные методологические обсуждения: предположим, вы хотите наследовать от класса итератора, такого как LINEAR_ITERATOR ; следует ли использовать наследование модуля или наследование типа? Можно приводить аргументы в защиту одного и другого решения. Вклад этого предложения в критерий качества нашего ПО и скорость его создания будут фактически нулевыми.

В упражнении У6.8 требуется проанализировать наши категории, отнеся их либо к наследованию модуля, либо к наследованию типа.

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

  • Переопределение полезно как для подтипов (вспомните RECTANGLE, переопределяющий perimeter от POLYGON ) и для расширения модуля (принцип Открыт-Закрыт требует при наследовании модуля сохранения гибкости изменений, без чего будет потеряно одно из главных преимуществ ОО-метода).
  • Переименование полезно при наследовании модуля. Полагать его неподходящим при наследовании типа (см. [Breu 1995]) представляется серьезным ограничением. При моделировании внешней системы варианты некоторого понятия могут вводить специальную терминологию, которую желательно сохранить в ПО. Класс STATE_INSTITUTIONS в географической или выборной информационной системе может иметь потомка LOUISIANA_INSTITUTIONS, отражающего особенности политической структуры штата Луизиана, поэтому вполне ожидаемо желание потомка переименовать компонент counties, задающий список округов штатов, в parishes - имя, используемое для округа в данном штате.
  • Дублируемое наследование может встретиться для любой из форм. Так как можно ожидать, что только наследование модуля сохранит полиморфную подстановку, то при наследовании типов тут же возникнет необходимость разбора случаев и предложения select со всеми недостатками при появлении новых случаев. Появляются и другие вопросы - когда разделять компоненты, а когда их дублировать.
  • При введении в язык новых механизмов они взаимодействуют друг с другом и с другими механизмами языка. Должны ли мы защитить класс от совместного наследования и модуля, и типа? Если да, то будут возмущены разработчики, использующие класс двумя возможными способами, если нет, мы откроем ящик Пандоры, грозящий появлением множества проблем - конфликтов имен, переопределений и так далее.

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

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

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

Наследование подтипов и скрытие потомков

Первая категория наследования из нашего списка, вероятно, единственная, с которой согласится каждый, по меньшей мере, тот, кто принимает наследование: то, что мы можем назвать чистым наследованием подтипов (типов).

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

Определение подтипа

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

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

  • FIGURE \gets CLOSED_FIGURE \gets POLYGON \gets QUADRANGLE \gets RECTANGLE \gets SQUARE
  • DEVICE \gets FILE \gets TEXT_FILE
  • SHIP \gets LEISURE_SHIP \gets SAILBOAT
  • ACCOUNT \gets SAVINGS_ACCOUNT \gets FIXED_RATE_ACCOUNT

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

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

Некоторые из примеров, такие как RECTANGLE \gets SQUARE, возможно, включают эффективного родителя и потому представляют случаи наследования с ограничением.

Различные взгляды

Наследование подтипов кажется простым, когда существует четкий критерий классификации вариантов определенного понятия. Но иногда некоторые качества начинают конкурировать между собой. Даже в, казалось бы, совсем простом случае классификации многоугольников могут возникать сомнения: следует ли использовать для классификации число сторон, создавая такие классы, как TRIANGLE, QUADRANGLE etc., или следует разделять объекты на правильные многоугольники ( EQUILATERAL_POLYGON, SQUARE и т. д.) и неправильные?

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

Взгляд на подтипы

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

Правила, применяемые к утверждениям, поддерживают этот взгляд на подтипы:

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

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

Для чистого наследования подтипов можно предложить более сильное правило: каждый компонент, применимый клиентом к экземплярам родительского класса, тем же клиентом может быть применен к экземплярам наследника. Другими словами, нет скрытия компонента потомком: если B наследует f от A, то статус экспорта f в B не менее широкий, чем в A. (Так что общеэкспортируемый компонент f таковым и остается, а выборочно экспортируемый может только расширить круг клиентов.)

Необходимость скрытия потомком

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

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

Альтернативы обсуждались при рассмотрении основополагающего принципа Открыт-Закрыт и они не кажутся привлекательными:

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

Как избежать скрытия потомком

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

Рассмотрим класс ELLIPSE. Эллипс имеет два фокуса, через которые можно провести прямую:

Эллипс и фокусная линия

Рис. 6.9. Эллипс и фокусная линия

Класс ELLIPSE может соответственно иметь компонент focus_line.

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

Круг и его центр

Рис. 6.10. Круг и его центр

Хороший ли это пример для скрытия потомком? Должен ли класс CIRCLE сделать компонент focus_line закрытым, как здесь:

class CIRCLE inherit
    ELLIPSE
        export {NONE} focus_line end
    ...

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

focus_line is
        -- Линия, проходящая через два фокуса
    require
        not equal (focus_1, focus_2)
    do
        ...
    end

(Предусловие может быть абстрактным, использующим функцию distinct_focuses ; с тем преимуществом, что класс CIRCLE может сам переопределить ее.)

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

Приложения скрытия потомком

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

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

Рассмотрим иерархию с корневым классом MORTGAGE ( ЗАКЛАДНАЯ ). Потомки организуются в соответствии с различными критериями, такими как фиксированная или переменная ставка, деловая или персональная, любыми другими. Для простоты будем полагать, что речь идет о таксономии - чистом случае подтипов. Класс MORTGAGE имеет процедуру redeem (выплачивать долг по закладной), управляющей выплатами по закладной в некоторый период, предшествующий сроку оплаты.

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

Можно было бы использовать технику предусловий, как в случае с focus_line. Но что, если банкиру никогда не приходилось иметь дело с закладными, по которым нельзя платить досрочно? Тогда, вероятно, процедура redeem не будет иметь предусловия.

Так что использование предусловия потребует модификации класса MORTGAGE со всеми вытекающими последствиями. Предположим, однако, что в данном случае проблем с модификацией не будет, и мы добавим в класс булеву функцию redeemable и предусловие к redeem:

require
    redeemable

Но тем самым мы изменили интерфейс класса. Все клиенты класса и их бесчисленные потомки мгновенно стали потенциально некорректными. Все их вызовы m.redeem (...) должны быть теперь переписаны как:

if m.redeemable then
    m.redeem (...)
else
    ... (Кто в мире мог предвидеть это?)...
end

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

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

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

class NEW_MORTGAGE inherit
    MORTGAGE
        export {NONE} redeem end
    ...

Ни ошибок, ни аномалий не появится в существующем ПО. Если кто-то модифицирует класс клиента, добавив класс с новыми закладными:

m: MORTGAGE; nm: NEW_MORTGAGE
...
m := nm
...
m.redeem (...)

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

Таксономии и их ограничения

Исключения таксономии не являются спецификой программистских примеров. В большей степени они характерны для естественных наук, где почти невозможно найти утверждение в форме "члены ABC phylum [или genus, species etc.] характеризуются свойством XYZ ", которому не предшествовало бы " большинство ", " обычно " или за которым не следовало бы " за исключением нескольких случаев ". Это справедливо на всех уровнях иерархии, даже для наиболее фундаментальных категорий, для которых, казалось бы, существуют бесспорные критерии!

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

Отличие растений от животных

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

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

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

Рост. Растения обычно растут от концов своих ветвей и корней и от внешних участков ствола в течение всей жизни. У животных рост обычно идет во всех частях их тела и прекращается при достижении зрелости.

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

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

Известный пример из зоологии, ставший уже клише, иллюстрирует таксономию исключений. (Помните, однако, что это только аналогия, а не программистский пример.) Птицы летают. (Класс BIRD должен иметь процедуру fly.) Страус - птица, но страус не летает. При создании наследника - класса OSTRICH - необходимо будет указать, что эта самая большая из птиц не летает.

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

Пример со страусом ( OSTRICH ) имеет интересный поворот. Хотя, к сожалению, они и не сознают этого, страусы фактически должны летать. Молодые поколения теряют этот наследственный навык из-за случайности эволюционной истории. Анатомически страусы являются самой совершенной аэродинамической машиной среди всех птиц. Это свойство, немного осложняя работу профессионального таксономиста, (хотя ее может облегчить его коллега - профессиональный таксидермист) не помешает классифицировать страусов в иерархии птиц.

В программистских терминах класс OSTRICH будет наследником BIRD, скрывая наследуемый компонент fly.

Использование скрытия потомком

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

Практика разработки ПО и аналогии природного мира свидетельствуют, что даже при самом тщательном проектировании остаются исключения таксономии. Скрытие redeem класса NEW_MORTGAGE или fly из OSTRICH не является свидетельством небрежного проектирования или недостаточного предвидения, оно свидетельствует о реальной сложности иерархии наследования.

Такие исключения таксономии имеют прецеденты, насчитывающие столетние усилия интеллектуальных гигантов (включая Аристотеля, Линнея, Бюффона и Дарвина). Они сигнализируют о внутренних ограничениях человеческой способности познания мира. Связаны ли они с результатами, шокирующими научную мысль в двадцатом столетии - принципом неопределенности в физике, неразрешимыми проблемами в математике?

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

Наследование реализации

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

Брак по расчету

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

class ARRAYED_STACK [G] inherit
    STACK [G]
        redefine change_top end
    ARRAY [G]
        rename
            count as capacity, put as array_put
        export
            {NONE} all
        end
feature
    ... Реализация отложенных программ STACK, таких как put, count, full...
    ... и переопределение change_top в терминах операций ARRAY ...
end

Интересно сравнить представленную схему класса ARRAYED_STACK с классом STACK2 из предыдущих обсуждений (см. лекцию 11 курса "Основы объектно-ориентированного программирования") - реализацию стека массивом, но без использования наследования. Заметьте, устранение необходимости быть клиентом ARRAY упрощает нотацию (предыдущая версия должна была использовать вызов в форме implementation.put, теперь можно писать просто put ).

При наследовании все компоненты ARRAY были сделаны закрытыми. Это типично при браках по расчету: все компоненты родителя, обеспечивающего спецификацию, здесь STACK, экспортируются; все компоненты родителя, обеспечивающего реализацию, здесь ARRAY, скрываются. Это вынуждает клиентов класса ARRAYED_STACK использовать соответствующие экземпляры только через компоненты стека.

Это выглядит привлекательно, но правильно ли это?

Наследование реализации подвергается критике. Скрытие компонентов кажется нарушением отношения "is-a".

Это не так. У этого отношения есть разные формы. По своему поведению стек, основанный на массиве, ведет себя как стек. По внутреннему представлению он массив, и экземпляры ARRAYED_STACK отличаются от экземпляров ARRAY лишь обогащением за счет атрибута ( count ). Экземпляры, создаваемые по единому образцу, представляют достаточно строгую форму отношения "is-a". И дело не только в представлении: все компоненты ARRAY, такие как put (переименованный в array_put ), infix "@" и count (переименованный capacity ), доступны для ARRAYED_STACK, хотя и не экспортируются его клиентам. Классу они необходимы для реализации компонентов STACK.

Так что концептуально ничего ошибочного нет в наследовании в интересах реализации. Показательно сравнение с контрпримером, изучаемым в начале этой лекции, класс CAR_OWNER свидетельство непонимания концепции наследования, класс ARRAYED_STACK задает хорошо определенную форму отношения "is-a".

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

Как это делается без наследования

Давайте проверим, как можно выполнить эту работу без использования наследования. Для нашего примера это было уже сделано в классе STACK2 из предыдущих лекций. Он имеет атрибут representation типа ARRAY [G] и процедуры стека, реализованные в следующем виде (утверждения опущены):

put (x: G) is
        -- Добавляет x на вершину
    require
        ...
    do
        count := count + 1
        representation.put (count, x)
    ensure
        ...
    end

Каждая манипуляция с представлением требует вызова компонента ARRAY с representation как цели. Платой являются потери производительности: минимальные по памяти (атрибут representation ), более серьезные по времени (связанные с representation, накладные расходы, добавляемые при вызове каждой операции).

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

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

indexing
    description: "Объекты, имеющие доступ к массиву и его операциям"
class
    ARRAYED [G]
feature -- Access
    item (i: INTEGER): G is
            -- Элемент представления с индексом i
        require
            ...
        do
            Result := representation.item (i)
        ensure
            ...
        end
feature -- Element change
    put (x: G; i: INTEGER) is
            -- Замена на x элемента с индексом i
        require
            ...
        do
            representation.put (x, i)
        ensure
            ...
        end
feature {NONE} -- Implementation
    representation: ARRAY [G]
end

Компоненты item и put экспортированы. Так как ARRAYED описывает только внутренние свойства структуры данных, нет реальной необходимости в экспортируемых компонентах. Так что тот, кто не согласен с самой идей разрешения потомкам скрывать некоторые из экспортируемых компонентов, может предпочесть сделать закрытыми все компоненты ARRAYED. По умолчанию они тогда будут скрытыми и у потомков.

При таком определении класса не вызывает споров, что классы, такие как ARRAYED_STACK или ARRAYED_LIST, становятся наследниками ARRAYED: они действительно описывают структуры на массивах. Эти классы могут теперь использовать item вместо representation.item и так далее; мы избавились от утомительного повторения.

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

На самом деле нет никаких причин для введения класса ARRAYED. Прямое наследование реализации от классов, подобных ARRAY, проще и легитимнее.

Льготное наследование

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

Использование кодов символов

Библиотека Base Libraries включает класс ASCII:

indexing
    description:
    "Множество символов ASCII. %
    %Этот класс - предок всех классов, нуждающихся в его свойствах."
class ASCII feature -- Access
    Character_set_size: INTEGER is 128; Last_ascii: INTEGER is 127
    First_printable: INTEGER is 32; Last_printable: INTEGER is 126
    Letter_layout: INTEGER is 70
    Case_diff: INTEGER is 32
        -- Lower_a - Upper_a
    ...
    Ctrl_a: INTEGER is 1; Soh: INTEGER is 1
    Ctrl_b: INTEGER is 2; Stx: INTEGER is 2
    ...
    Blank: INTEGER is 32; Sp: INTEGER is 32
    Exclamation: INTEGER is 33; Doublequote: INTEGER is 34
    ...
    ...
    Upper_a: INTEGER is 65; Upper_b: INTEGER is 66
    ...
    Lower_a: INTEGER is 97; Lower_b: INTEGER is 98
    ... и т.д. ...
end

Этот класс является хранилищем множества константных атрибутов (всего 142 компонента), описывающих свойства множества ASCII.

Рассмотрим, например, лексический анализатор, ответственный за идентификацию лексем входного текста. Лексемами текста, написанного на некотором языке программирования, являются целые, идентификаторы, символы и так далее. Одному из классов системы, скажем, TOKENIZER, необходим доступ к кодам символов для их классификации на цифры, буквы и т. д. Такой класс воспользуется льготами и наследует эти коды от ASCII:

class TOKENIZER inherit ASCII feature
    ... Программы класса могут использовать компоненты Blank, Case_diff и другие...
end

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

Итераторы

Второй пример демонстрирует наследование программ общего вида, а не константных атрибутов.

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

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

В упражнении У6.7 предлагается показать, что это неправильное решение.

Предпочтительнее ввести независимую иерархию итераторов, показанную на рис. 6.11.

Иерархия итераторов

Рис. 6.11. Иерархия итераторов

Класс LINEAR_ITERATOR, один из наиболее интересных классов в этом обсуждении, выглядит так:

indexing
    description:
        "Объекты, допускающие итерирование на линейных структурах"
    names: iterators, iteration, linear_iterators, linear_iteration
deferred class LINEAR_ITERATOR [G] inherit
    ITERATOR [G]
        redefine target end
feature -- Access
    invariant_value: BOOLEAN is    
            -- Свойство, сопровождающее итерацию (по умолчанию: true)
        do
            Result:= True
        end
    target: LINEAR [G]
        -- Структура, к которой будут применяться компоненты итерации
    test: BOOLEAN is
        -- Булево условие выбора применимых элементов
        deferred
        end
feature - Basic operations
    action is
        -- Действие на выбранных элементах
        deferred
        end
    do_if is
        -- Применить action в последовательности к каждому элементу
        --target, удовлетворяющему test.
        do
            from start invariant invariant_value until exhausted loop
                if test then action end
                forth
            end
        ensure then
            exhausted
        end
    ... И так далее: do_all, do_while, do_until и другие процедуры ...
end

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

class JUSTIFIER inherit
    LINEAR_ITERATOR [PARAGRAPH]
        rename
            action as justify,
            test as justifiable,
            do_all as justify_all
        end
feature
    justify is
        do ... end
    justifiable is
            -- Подлежит ли абзац проверке?
        do
            Result := not preformated
        end
    ...
end

Переименование облегчает понимание. Заметьте, нет необходимости в объявлении или повторном объявлении процедуры justify_all (бывшей do_all ): будучи наследуемой, ожидаемая работа будет проделана эффективными версиями action и test.

Процедура justify, вместо того чтобы быть описанной в классе, может наследоваться от другого родителя. В этом случае множественного наследования будет выполняться операция объединения ("join"), эффективизирующая отложенную action, наследуемую от одного родителя под именем justify (здесь переименование существенно), с эффективной justify, наследуемой от другого родителя. Реально, это и есть брак по расчету.

Класс LINEAR_ITERATOR является замечательным примером класса поведения (behavior class), рассматривая общее поведение и оставляя открытыми специфические компоненты, так чтобы его потомки могли подключить специальные варианты.

Формы льготного наследования

Два примера, ASCII и LINEAR_ITERATOR, демонстрируют два главных варианта льготного наследования:

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

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

Понимание льготного наследования

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

Главный вопрос, заслуживающий рассмотрения, связан не столько с наследованием, сколько с тем, как определены классы ASCII и LINEAR_ITERATOR. Как всегда, при рассмотрении проекта класса, следует спросить себя: "Действительно ли мы описали значимую абстракцию данных - множество объектов, характеризуемых абстрактными свойствами?"

Для этих примеров ответ менее очевиден, чем для классов RECTANGLE, BANK_ACCOUNT или LINKED_LIST, но, по сути, он тот же:

  • Класс ASCII представляет абстракцию: "любой объект, имеющий доступ к свойствам множества ASCII ".
  • Класс LINEAR_ITERATOR представляет абстракцию: "любой объект, способный выполнять последовательные итерации на линейной структуре". Такой объект имеет тенденцию быть "абстрактной машиной", описанной в "Принципы проектирования класса" .

Как только эти абстракции принимаются, наследственные связи не вызывают никаких проблем: экземпляру TOKENIZER необходим "доступ к свойствам множества ASCII", а экземпляр JUSTIFIER способен "выполнять последовательные итерации на линейной структуре". Фактически можно было бы классифицировать такие примеры как наследование подтипов. Что отличает льготное наследование, так это природа родителей. Эти классы являются исходными, не использующими наследование. И класс приложения может предпочесть быть их клиентом, а не наследником. Это утяжеляет подход, особенно для класса ASCII:

charset: ASCII
...
create charset

При каждом использовании кода символа потребуется задавать целевой объект charset.Lower_a. Присоединяемый объект charset не играет никакой полезной роли. Те же комментарии справедливы и для класса LINEAR_ITERATOR. Но если классу необходимы несколько видов итерации, то тогда создание объектов-итераторов с собственными версиями action и test становится разумным.

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