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

Почему в некоторых языках программирования отказываются от поддержки множественного наследования

  • автор:

К вопросу о многообразии форм наследования Текст научной статьи по специальности «Компьютерные и информационные науки»

объектно-ориентированное программирование / наследование / иерархия классов / по-вторное использование / принцип LSP / обобщение/специализация / object-oriented programming / inheritance / class hierarchy / reusability / LSP principle / generaliza-tion/concretization

Аннотация научной статьи по компьютерным и информационным наукам, автор научной работы — С. В. Логанов

Анализируются возможные формы наследования и правомерность обоснования их применения в стати-чески типизированных объектно-ориентированных языках. Приводится концепция целей построения иерархий классов наследования . Установлено, что правомерное использование наследования заключается в построении правильных абстракций для обобщения или специализации некоторого поведения, востребованного соответству-ющими клиентами. Применение наследования просто для повторного использования приводит к построению противоречивых иерархий, которые затруднены или невозможны для повторного использования. Предложена классификация форм наследования , обеспечивающих его адекватное применение в статически типизированных языках Java и C#.

i Надоели баннеры? Вы всегда можете отключить рекламу.

Похожие темы научных работ по компьютерным и информационным наукам , автор научной работы — С. В. Логанов

Ещё раз о принципах применения наследования
Построение иерархии классов по текстовым описаниям
«IS-THE»-отношения в семантических моделях данных: основные понятия и разновидности
Ключевые понятия и особенности объектно-ориентированного программирования
Теоретические аспекты паттерного программирования
i Не можете найти то, что вам нужно? Попробуйте сервис подбора литературы.
i Надоели баннеры? Вы всегда можете отключить рекламу.

ON THE VARIETY OF INHERITANCE CATEGORIES

Purpose: The article is dedicated to discussing possible categories of inheritance , their adequate and unreasonable use in statically typed object-oriented languages. The concept of the purpose of construction inheritance class hierarchies is given. Design/ methodology/ approach: The use of inheritance for reuse leads to the construction of conflicting hierarchies that are very difficult to reuse. Findings: The valid use of inheritance is to build the abstractions with extendable behavior require by the relevant clients. Research limitations/implications: Statically typed object-oriented languages. Originality/ value: Inheritance is not recommended for reuse because it is a very strong coupling between classes.

Текст научной работы на тему «К вопросу о многообразии форм наследования»

К ВОПРОСУ О МНОГООБРАЗИИ ФОРМ НАСЛЕДОВАНИЯ

Нижегородский государственный технический университет им. Р.Е. Алексеева

Анализируются возможные формы наследования и правомерность обоснования их применения в статически типизированных объектно-ориентированных языках. Приводится концепция целей построения иерархий классов наследования. Установлено, что правомерное использование наследования заключается в построении правильных абстракций для обобщения или специализации некоторого поведения, востребованного соответствующими клиентами. Применение наследования просто для повторного использования приводит к построению противоречивых иерархий, которые затруднены или невозможны для повторного использования. Предложена классификация форм наследования, обеспечивающих его адекватное применение в статически типизированных языках Java и C#.

Ключевые слова: объектно-ориентированное программирование, наследование, иерархия классов, повторное использование, принцип LSP, обобщение/специализация.

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

• порождение подклассов для специализации;

• порождение подкласса для спецификации;

• порождение подкласса для расширения;

• порождение подкласса с целью конструирования;

• порождение подкласса для обобщения;

• порождение подкласса для ограничения;

• порождение подкласса для варьирования;

• порождение подкласса для комбинирования.

При порождении подклассов для специализации имеет значение то, что порожденный класс является специализированной версией родительского класса и «удовлетворяет спецификациям родителя во всех существенных моментах» [1]. Таким образом, для этой формы полностью выполняется принцип подстановки Liskov (LSP) и, по мнению автора, «вместе со следующей категорией (наследование для спецификации) специализация является наиболее идеальной формой наследования, к которой должна стремиться хорошая программа» [1]. При порождении подкласса для спецификации родительский класс описывает поведение, отложенное для реализации дочерним классом. Родительский класс для этой формы является абстрактным или даже интерфейсом, а дочерний реализует заданное поведение и, следовательно, также должен удовлетворять принципу LSP. Порождение подкласса для расширения характеризуется тем, что дочерний класс добавляет новые функциональные возможности к родительскому классу, но не меняет наследуемое поведение. В данном случае у дочернего класса появляются собственные клиенты, которые пользуются его расширенным поведением, и он также удовлетворяет принципу LSP, поскольку не изменяет наследуемое поведение.

Порождение подкласса с целью конструирования предполагает, что дочерний класс использует методы, предоставляемые родительским классом, но не является подтипом родительского класса, т.е., реализация методов нарушает принцип LSP. В данном случае даже сам автор делает оговорку, что «дочерний класс не является более специализированной формой родительского класса, так как у нас и в мыслях не будет подставлять представителей дочернего класса туда, где используются представители родительского класса» [1] и что «в языках со

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

При порождении подкласса для обобщения подкласс расширяет родительский класс для создания объекта более общего типа. «Например, в систему графического отображения, в которой был определен класс окон Window для черно-белого фона необходимо добавить тип цветных графических окон ColoredWindow. Цвет фона будет отличаться от белого за счет добавления нового поля, содержащего цвет. Необходимо также переопределить наследуемую процедуру изображения окна, в которой происходит фоновая заливка» [1]. Тем не менее, в данном случае класс ColoredWindow должен следовать принципу LSP. В противном случае, если, например, ColoredWindow забудет восстановить цвет фоновой кисти, то все последующие объекты класса Window изменят свой цвет. Такое новое поведение явно будет считаться ошибочным. Поэтому такое поведение подкласса следует считать специализацией, поскольку «нарисовать цветное окно» является специальным случаем просто «нарисовать окно». Кроме этого, признается: «Как правило, следует избегать порождения подкласса для обобщения, пользуясь перевернутой иерархией типов и порождением для специализации. Однако это не всегда возможно» [1]. Единственной причиной является отсутствие исходного кода классов, а, следовательно, расположение этого кода в другом компоненте. Наследование класса из другого компонента приводит к сильной зависимости этих компонентов. А если компоненты различны, то и причины изменений этих компонентов различны. Лучшим решением в данном случае является изоляция этих компонентов с помощью принципа инверсии зависимостей (DIP) [3]. Таким образом, нельзя считать хорошей практикой наследование класса из другого компонента.

Порождение подкласса для ограничения происходит, когда возможности подкласса более ограничены, чем в родительском классе. «Так же, как и при обобщении, порождение для ограничения чаще всего возникает, когда программист строит класс на основе существующей иерархии, которая не должна или не может быть изменена» [1]. Тогда возникает закономерный вопрос: зачем вообще нарушать стройность существующей иерархии добавлением такого нового класса?

Попадание экземпляров такого нового класса в качестве абстракции, существующей иерархии, для использования соответствующим клиентом приведет к появлению неожиданных сюрпризов в его поведении. Помимо этого, и сам автор признает, что данную форму наследования следует по возможности избегать. Такая возможность всегда существует в виде использования клиентского отношения и отсутствия необходимости наличия отношения заменяемости. «Порождение подкласса для варьирования применяется, когда два класса имеют сходную реализацию, но не имеют никакой видимой иерархической связи между абстрактными понятиями, ими представляемыми. Например, программный код для управления мышкой может быть почти идентичным тому, что требуется для управления графическим планшетом. Теоретически, однако, нет никаких причин, для того чтобы класс Mouse, управляющий манипулятором «мышь», был подклассом класса Tablet, контролирующего графический планшет, или наоборот. В данном случае в качестве родителя произвольно выбирается один из них, при этом другой наследует общую программную часть кода и переопределяет код, зависящий от устройства». Данная проблема является типовой проблемой шаблона GRASP «Защита от изменений» [4] и решается с помощью введения необходимого интерфейса, обеспечивающего изоляцию клиента от конкретных деталей реализации. Применение наследования в данной ситуации обеспечивает слишком сильную взаимозависимость данных классов, имеющую соот-

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

Порождение подкласса для комбинирования применяется, когда дочерний класс наследует черты более чем одного родительского класса. Это множественное наследование, которое объединяет линии поведения родительских классов и тем самым нарушает принцип единственности обязанности ^ЯР), поскольку обязанности родительских классов принципиально различны, иначе они не были бы различными классами. Поэтому множественное наследование было исключено из языков, которые считаются типовыми объектно-ориентированными языками и заменено реализацией интерфейсов, которая позволяет объекту выступить в роли другого объекта, не изменяя своей первоначальной сущности. Однако и в этом случае дочерний класс должен соблюдать принцип LSP, чтобы не преподносить сюрпризов клиентам родительских классов. В [2] представлены те же самые формы наследования с той лишь разницей, что опущено наследование для варьирования, а наследование для спецификации названо наследованием для реализации. Однако, при описании наследования для конструирования приводится следующий пример. «Путем наследования класса «Экономичное Окно» конструируется класс Самолет, в котором родительский метод изменить положение () превращается в метод летать (), а от методов свернуть () и развернуть () вообще отказываются». Такое использование наследование вообще нельзя признать более или менее разумным. Это аналогично тому чтобы в реальной жизни пытаться сконструировать мобильный телефон на основе молотка. По словам автора, «конечно, бывают ситуации, когда наследование применимо, даже если тест «является» не пройден» [2]. В [5] однозначно показывается, что не следует использовать наследование, если тест «¡б-я» не пройден. Желание использовать наследование как механизм повторного использования как можно чаще приводит к тому, что появились такие экзотические формы наследования как для обобщения, ограничения, конструирования и варьирования, которые в объектно-ориентированных языках со статической типизацией приносят лишь проблемы при сопровождении, поддержке и модернизации кода.

Более подробная классификация форм наследования (причем правильных форм наследования, соответствующих отношению «is-a») приведена в [5], где различаются следующие три общие категории (рис. 1):

• наследование модели, отражающее отношения «¡б-я» между абстракциями, характерными для модели предметной области;

• программное наследование, выражающее отношения между объектами программной системы, не имеющих очевидных двойников во внешней модели;

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

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

Рис. 1. Классификация форм наследования Б. Мейера

«Наследование подтипов является формой наследования ближайшей к иерархической таксономии в ботанике, зоологии и других естественных науках» [5]. Например, млекопитающие являются наследниками позвоночных и т.д. При этом следует оговорится, что цели моделирования в программной системе должны соответствовать целям классификации в естественных науках иначе ее использование теряет смысл. Также примером данного наследования может служить определение банковского счета, который может быть текущим счетом, сберегательным счетом и т.д. Наследование с ограничением применимо, если экземпляры класса потомка являются экземплярами родительского класса, удовлетворяющими некоторому дополнительному ограничению, выраженному, как часть инварианта потомка, не включенного в инвариант родителя. В качестве типичного примера наследования с ограничением приводится квадрат как наследник прямоугольника. Ограничением является утверждение, что сторона 1 = сторона 2, включаемое в инвариант класса квадрат. Однако, математика — это очень абстрактная область знаний, утверждающая лишь, то что в отдельных случаях у прямоугольников стороны могут оказаться равны. А по словам автора, «другим типичным примером является так-сомания, в котором простое булево свойство, такое как пол персоны (или свойство с несколькими фиксированными значениями, такое как цвет светофора), используется как критерий наследования, хотя нет важных вариантов компонентов, зависящих от свойства» [5]. При моделировании объектов реального мира стоит лишь уменьшить погрешность измерений и квадрат тут же перестает быть квадратом. Кроме этого, если, например, потребуется масштабирование моделируемых объектов с различными коэффициентами масштаба по осям, то едва ли пользователи захотят сохранения этого свойства. Таким образом, в каждом индивидуальном случае решение будет достаточно специфическим и опираться на потребности конкретных пользователей. Однако примером данной формы наследования может служить ограниченный банковский счет, являющийся потомком банковского счета, с которого запрещается снятие в день суммы, превышающей заданный порог. При этом клиенты родительского банковского счета должны быть готовы к тому, что со счета может сниматься запрошенная сумма не полностью. Тем не менее, данный пример, как и пример с моделированием геометрии объектов, являются частными случаями наследования подтипов.

Наследование с расширением применимо, когда потомок вводит компоненты, не представленные в родителе и неприменимые к его прямым экземплярам. При этом утверждается,

что класс родителя должен быть эффективным. Рассмотрим пример с абстрактным классом Shape, который декларирует операции перемещения и масштабирования. От данного класса можно унаследовать класс Rectangle, имеющий диагональ и способный вычислять ее длину. Данный пример является примером наследования с расширением, в котором родитель является абстрактным. Таким образом, опровергается то, что родитель должен быть всегда эффективным. Далее описывается следующий парадокс наследования. «Присутствие обоих вариантов — расширения и сужения (ограничения) — является одним из парадоксов наследования. Расширение применяется к компонентам, в то время как ограничение (понимаемое как специализация) применяется к экземплярам. Проблема в том, что добавляемые компоненты обычно включают атрибуты. Так что при наивной интерпретации типа (заданного классом) как множества его экземпляров отношение между классом и наследником (рассматриваемых как множества) «быть подмножеством» становится полностью ошибочным» [5]. Далее рассматривается пример, в котором экземпляры родительского класса образуют одноэлементное множество, записываемое как , где n целое. А экземпляры дочерних классов — как пару, содержащую целое и вещественное, записываемое как . На этом основании утверждается, что множество пар дочерних экземпляров не является подмножеством одноэлементного множества родительских экземпляров. А верно обратное: отношение «быть подмножеством» имеет место в обратном направлении. Однако это — чисто математическая трактовка данных множеств. Проблема заключается в том, данный пример рассматривается в отсутствие клиента родительского класса, для которого множество парных экземпляров может быть представлено как множество одноэлементных экземпляров. При этом экземпляры как одноэлементного множества, так и двухэлементного множества различны для клиента, а, следовательно, множество дочерних экземпляров является подмножеством родительского типа. Таким образом, не существует никакого парадокса наследования. Далее и сам автор решает данную проблему, применив для этого другие математические абстракции. Тем не менее, наследование с расширением также следует отнести к частному случаю наследования подтипов.

Наследование вида используется там, где единый критерий классификации кажется ограничительным. В этом случае предлагается использовать «приемы множественного и особенно дублирующего наследования». Далее приводится класс EMPLOYEE в системе управления персоналом, для которого существуют два различных критерия классификации служащих по типу контракта (временные или постоянные работники) и по типу исполняемой работы (инженерная, административная, управленческая). Поэтому для соблюдения «идей использования наследования для классификации, следует ввести промежуточный уровень (два вида служащих), описывающий конкурирующие критерии классификации» [5]. «При наследовании подтипов предполагается, что экземпляры наследников принадлежат непересекающимся подмножествам множества, заданного родителем. При наследовании видов: различные наследники некоторого класса представляют не непересекающиеся подмножества его экземпляров, а различные способы классификации экземпляров родителя» [5]. Наличие двух критериев классификации в наследовании видов говорит о том, что абстракция EMPLOYEE используется двумя различными способами, а, следовательно, содержит две линии поведения (обязанности), что противоречит принципу единственности ответственности со всеми вытекающими негативными последствиями. Поэтому рекомендовать данную форму наследования как хорошую практику программирования вряд ли целесообразно. Кроме того, сам автор не рекомендует применять данную форму наследования, особенно новичкам. «Прежде всего, должно быть ясно, что, подобно дублируемому наследованию, наследование видов не является механизмом для новичков. Правило осмотрительности, введенное для дублируемого наследования, справедливо и здесь: если ваш опыт разработки ОО-проектов измеряется несколькими месяцами, избегайте наследования видов» [5].

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

конкретных задач разработчика намного важнее, чем построение стройной иерархии ради самой иерархии и принесет более весомую выгоду особенно для долговременной эксплуатации и поддержке программного кода. Наследование вариаций применяется, если класс потомок переопределяет некоторые компоненты класса родителя. «Класс потомок при этом не должен вводить никаких новых компонентов за исключением тех, что непосредственно необходимы переопределяемым компонентам, что обеспечивает отличие данной формы наследования от наследования с расширением» [5]. Наследование вариаций функций переопределяет только тела компонентов, а при наследовании вариаций типа все переопределения являются переопределениями сигнатур. Наследование вариаций применимо, когда существующий класс родителя задает некоторую абстракцию, полезную саму по себе, но обнаруживается необходимость представления подобной, хотя и не идентичной, абстракции, имеющей те же компоненты, но с отличиями в сигнатуре или реализации.

При этом автор настаивает, чтобы оба класса были либо эффективными, либо отложенными. «Такое наследование не рассматривает эффективизацию компонентов, когда речь идет о переходе от абстрактной формы к конкретной» [5]. Эффективизацию компонентов автор отнес чисто к программной форме наследования — к наследованию с овеществлением. Однако и при моделировании реальных объектов такая эффективизация может понадобиться. Это возможно, например, при моделировании транспортных средств, имеющих функцию перемещения, которая, вероятнее всего, будет отложенной, поскольку наземные, воздушные и водные транспортные средства перемещаются совершенно по-разному. Таким образом, наличие отложенного метода перемещения и отсутствии необходимости более детальной классификации транспортных средств приводит к тому, что класс транспортных средств будет абстрактным (отложенным), а его потомки — эффективными. Наследование вариаций функции также следует отнести к наследованию подтипов, т.е., когда имеется некоторое множество объектов, обладающих более специфическим поведением. А наследование вариаций типа следует отнести к наследованию с расширением, так как новые компоненты с измененной сигнатурой у родительского класса недоступны, и у дочернего класса должны быть собственные клиенты, которые имеют доступ к этим компонентам, иначе такое наследование было бы бессмысленным. К данной группе наследования принадлежит и категория деэффективизации, в которой некоторые эффективные компоненты становятся отложенными. По утверждению автора, такая форма наследования может быть законной в двух случаях.

В первом случае при множественном наследовании сливаются компоненты, наследуемые от двух различных родителей. «Если один из них отложенный, а другой эффективный, то слияние произойдет автоматически при условии совпадения имен (возможно после переименования), эффективная версия будет определять реализацию. Но если обе версии эффективны, следует провести потерю эффективизации одной версии, отдавая предпочтение другой версии» [5]. Однако в современных статически типизированных языках автоматическое слияние произойдет, если отложенный класс является интерфейсом и соответственно наследование в данном случае лучше назвать эффективизацией интерфесов. Если же оба родительских класса будут эффективными, такое наследование становится принципиально невозможным.

Во втором случае: «Хотя абстракция соответствует потребностям, но повторно используемый класс слишком конкретен для наших целей. Отмена эффективизации позволит удалить нежеланную реализацию» [5]. Даже сам автор оговаривает, что перед использованием этого решения следует рассмотреть альтернативу — реорганизовать иерархию наследования, сделав более конкретный класс наследником нового отложенного класса. «По понятным причинам, это не всегда возможно, например, из-за отсутствия доступа к исходному коду» [5]. Но если родительский класс принадлежит другому компоненту, и, тем более, если у него отсутствует исходный код, использование такой сильной связи как наследование приведет лишь к дополнительным проблемам сопровождения и поддержки кода. Взаимодействие различных физических компонентов лучше разделить с помощью принципа DIP, обеспечивающего их взаимо-

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

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

Структурное наследование применяется, когда родительский отложенный класс представляет общее структурное свойство, а потомок, который может быть отложенным или эффективным, представляет некоторый тип объектов, обладающих этим свойством. «Например, родительский класс может быть классом COMPARABLE, представляющим объекты с заданным отношением полного порядка. Класс, которому необходимо отношение порядка, становится наследником класса COMPARABLE» [5]. Разница между овеществлением и структурным наследованием заключается в том, что при овеществлении дочерний класс представляет собой то же понятие, что и родительский, отличаясь большей степенью реализации. А при структурном наследовании дочерний класс представляет собственную абстракцию, для которой родительский задает лишь один из аспектов, реализация которого позволяет ему, например, поучаствовать в алгоритмах упорядочивания, выполняемых некоторыми классами. Такая ситуация может существовать не только между программными объектами, но и между объектами реальной предметной области. Например, служащий может оказаться в роли члена профсоюза или в роли спортсмена, не переставая при этом быть собственно служащим в возможной иерархии наследования служащих. Таким образом, данная форма наследования в статически типизированных языках относится к наследованию интерфейса.

Наследованием реализации является «брак по расчету», основанный на множественном наследовании, где один из родителей обеспечивает спецификацию, а другой — предоставляет реализацию. В качестве примера приводится стек, основанный на массиве ARRAYED_STACK. При этом наследование от ARRAY выполняется закрытым. Это вынуждает клиентов класса ARRAYED_STACK использовать соответствующие экземпляры только через компоненты стека. Для статически типизируемых языков закрытое наследование в принципе невозможно, а, следовательно, невозможно гарантировать неиспользуемость стека как массива. Преимущество по сравнению с клиентским отношением, которое дает данная форма наследования — это возможность использовать функции родительского класса без использования имени переменной. Это является сомнительным преимуществом, если принять во внимание, что за его использование отрезается возможность расширения гибкости клиентского класса за счет применения принципа открытости/закрытости (OCP). Это и было продемонстрирована автором приведением примера с техникой описателей (handle). Наследование возможностей (льготное) является схемой, в которой родитель представляет коллекцию полезных компонентов, предназначенных для использования его потомками. Оно применяется, если родительский класс существует единственно в целях обеспечения множества логически связанных компонентов, дающих преимущества его потомкам.

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

множества ASCII и унаследованный от него класс лексического анализатора TOKENIZER, ответственный за идентификацию лексем входного текста. «Лексемами текста, написанного на некотором языке программирования, являются целые, идентификаторы, символы и так далее и TOKENIZER, необходим доступ к кодам символов для их классификации на цифры, буквы и т. д. Такой класс воспользуется льготами и наследует эти коды от ASCII» [5]. Однако в данном случае класс ASCII не реализует правильную абстракцию и не имеет функций по определению принадлежности символа к цифрам, буквам, управляющим символам и т.д. Приведение класса ASCII к такой абстракции и использование клиентского отношения позволяет развивать гибкость новой абстракции с помощью принципа OCP и добавления новых функций, непосредственно полезных лексическому анализатору. Кроме этого, при переходе на юникод-ные символы использование наследования констант приведет к необходимости полного переписывания кода класса TOKENIZER и не способствует повторному использованию кода.

Второй формой наследования возможностей является наследование абстрактной машины, в котором компоненты родительского класса являются подпрограммами, рассматриваемыми в качестве операций абстрактной машины. Иными словами, родительский класс представляет абстракцию выполнения некоторого алгоритма над объектами дочернего класса, которые реализуют соответствующие абстрактные функции. Преимуществом данной формы является возможность переименования функций родителя, которое позволяет дать им более благозвучное название, соответствующее назначению потомка. Поскольку переименование функций родителя в языках Java и C# невозможно, теряется и данное преимущество. Поэтому в данном случае более уместно определение контракта (интерфейса) для соответствующих абстрактных функций, реализацию которого должны обеспечить объекты, участвующие в выполнении необходимого алгоритма. Построение объектно-ориентированной программной системы — это моделирование как самой предметной области, так и программных задач, возникающих по ее визуальному представлению, сохранению и т.п. Модели для решения задач предметной области концептуально не отличаются от моделей решения программных задач, но они должны быть различны, так как различны сами задачи и тенденции их временного развития. Невозможно построить универсальную модель, пригодную на все случаи жизни, поэтому человечество давно научилось пользоваться принципом «разделяй и властвуй» с помощью построения упрощенных различных моделей для решения различных задач.

Таким образом, для таких статически типизированных языков, как Java и C#, правомерное использование наследования заключается в безусловном соблюдении правила «is-a» и применении следующих форм наследования (рис. 2).

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

Рис. 2. Правомерные формы наследования для статически типизированных языков

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

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

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

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

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

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

Предложена классификация форм наследования, обеспечивающих его свободное применение в таких статически типизированных языках, как Java и C#.

1. Бадд, Т. Объектно-ориентированное программирование в действии / Т. Бадд. — СПб.: Питер, 1997. — 464 с.

2. Орлов, С.А. Теория и практика языков программирования: учебник для вузов / С.А. Орлов. — СПб.: Питер, 2013. — 688 с.

3. Мартин, Р. Чистая архитектура. Искусство разработки программного обеспечения / Р. Мартин. -СПб.: Питер, 2018. — 352 с.

4. Ларман, К. Применение ИМЬ2.0 и шаблонов проектирования. Введение в объектно-ориентированный анализ и проектирование: учеб. пособие / К. Ларман. — М.: Вильямс, 2008. — 736 с.

5. Мейер, Б. Объектно-ориентированное конструирование программных систем / Б. Мейер. — М.: Русская редакция, 2005. — 768 с.

Дата поступления в редакцию: 01.06.2019

ON THE VARIETY OF INHERITANCE CATEGORIES

Nizhny Novgorod state technical university n.a. R.E. Alekseev

Purpose: The article is dedicated to discussing possible categories of inheritance, their adequate and unreasonable use in statically typed object-oriented languages. The concept of the purpose of construction inheritance class hierarchies is given.

Design/ methodology/ approach: The use of inheritance for reuse leads to the construction of conflicting hierarchies that are very difficult to reuse.

Findings: The valid use of inheritance is to build the abstractions with extendable behavior require by the relevant clients. Research limitations/implications: Statically typed object-oriented languages.

Originality/ value: Inheritance is not recommended for reuse because it is a very strong coupling between classes.

Key words: object-oriented programming, inheritance, class hierarchy, reusability, LSP principle, generaliza-tion/concretization.

Множественное наследование в Java. Композиция в сравнении с Наследованием

Java-университет

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

Ромбовидная проблема

Множественное наследование в Java. Композиция в сравнении с Наследованием - 1

Для более легкого понимания ромбовидной проблемы, давайте предположим, что множественное наследование поддерживается в Java. В этом случае, у нас могла бы быть иерархия классов как показано на изображении ниже. Предположим, что класс SuperClass является абстрактным и в нем объявлен некоторый метод. И конкретные классы ClassA и ClassB .

 package com.journaldev.inheritance; public abstract class SuperClass < public abstract void doSomething(); >package com.journaldev.inheritance; public class ClassA extends SuperClass < @Override public void doSomething()< System.out.println("doSomething implementation of A"); >//ClassA own method public void methodA() < >> package com.journaldev.inheritance; public class ClassB extends SuperClass < @Override public void doSomething()< System.out.println("doSomething implementation of B"); >//ClassB specific method public void methodB() < >> 

Теперь предположим мы хотим реализовать ClassC и наследовать его от ClassA и ClassB .

 package com.journaldev.inheritance; public class ClassC extends ClassA, ClassB < public void test()< //calling super class method doSomething(); >> 

Обратите внимание, что метод test() вызывает метод суперкласса doSomething() . Это приводит к неоднозначности, поскольку компилятор не знает, какой метод суперкласса выполнять. Это есть диаграмма классов ромбовидной формы, которая называется ромбовидная проблема. Это есть главная причина, по которой в Java не поддерживается множественное наследование. Заметьте, что вышеупомянутая проблема с многократным наследованием классов может произойти только с тремя классами, у которых есть хоть один общий метод.

Множественное наследование Интерфейсов

В Java множественное наследование не поддерживается в классах, но оно поддерживается в интерфейсах. И один интерфейс может расширять множество других интерфейсов. Ниже представлен простой пример.

 package com.journaldev.inheritance; public interface InterfaceA < public void doSomething(); >package com.journaldev.inheritance; public interface InterfaceB

Обратите внимание, что оба интерфейса объявляют один и тот же самый метод. Теперь мы можем создать интерфейс, расширяющий оба эти интерфейса, как представлено в примере ниже.

 package com.journaldev.inheritance; public interface InterfaceC extends InterfaceA, InterfaceB < //same method is declared in InterfaceA and InterfaceB both public void doSomething(); >

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

 package com.journaldev.inheritance; public class InterfacesImpl implements InterfaceA, InterfaceB, InterfaceC < @Override public void doSomething() < System.out.println("doSomething implementation of concrete class"); >public static void main(String[] args) < InterfaceA objA = new InterfacesImpl(); InterfaceB objB = new InterfacesImpl(); InterfaceC objC = new InterfacesImpl(); //all the method calls below are going to same concrete implementation objA.doSomething(); objB.doSomething(); objC.doSomething(); >> 

Обратите внимание, что каждый раз, переопределяя любой метод суперкласса или реализуя метод интерфейса используйте аннотацию @Override . Что делать, если мы хотим использовать функцию methodA() , класса ClassA и функцию methodB() , класса ClassB в классе ClassC ? Решение находится в использовании композиции. Ниже представлена версия класса ClassC , которая использует композицию, чтобы определить оба метода классов и метод doSomething() , одного из объектов.

 package com.journaldev.inheritance; public class ClassC < ClassA objA = new ClassA(); ClassB objB = new ClassB(); public void test()< objA.doSomething(); >public void methodA() < objA.methodA(); >public void methodB() < objB.methodB(); >> 

Композиция против Наследования

  1. Предположим, что у нас есть суперкласс и класс, расширяющий его:
 package com.journaldev.inheritance; public class ClassC < public void methodC()< >> package com.journaldev.inheritance; public class ClassD extends ClassC < public int test()< return 0; >> 

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

 package com.journaldev.inheritance; public class ClassC < public void methodC()< >public void test() < >> 
 package com.journaldev.inheritance; public class ClassC < SuperClass obj = null; public ClassC(SuperClass o)< this.obj = o; >public void test() < obj.doSomething(); >public static void main(String args[]) < ClassC obj1 = new ClassC(new ClassA()); ClassC obj2 = new ClassC(new ClassB()); obj1.test(); obj2.test(); >> 

Результат программы представленной выше:

 doSomething implementation of A doSomething implementation of B 

Сравнение ООП языков: Java, C++, Object Pascal (документация)

Ява — популярный в Интернете язык, c++, возможно, всё ещё самый распространённый язык ООП, а object pascal — язык, используемый фирмой inprise (бывшей borland) внутри delphi. Хотя это и не очевидно, но эти три языка имеют много общего. Цель данной работы — исследовать и сравнить технические аспекты этих трех языков. Я не собираюсь выяснять, какой из языков лучше, потому что это в значительной степени зависит от того, для чего вы хотите его использовать. Для понимания данной статьи требуется минимальное знание об одном из ОО языков или, по крайней мере, базовые знания о концепции ООП в целом. Я буду описывать, почему определённая языковая особенность важна, а затем перейду к её сравнению в упомянутых трёх языках. Я не собираюсь показывать вам, как использовать эти языковые особенности в конкретных примерах. Я не хочу научить вас ООП. Я только хочу сравнить эти языки.

Ключевые черты ООП

Объектно-ориентированное Программирование (ООП) не является новой техникой программирования. Его истоки восходят к Симуле-67, хотя первое полное осуществление относится к smalltalk-80. ООП стало популярным во второй половине 80-х в таких различных языках, как c++, objective-c (другое расширение c), object и turbo pascal, clos (ОО-расширение lisp), eiffel, ada (в её последних воплощениях) и недавно — в Яве. В этой работе я сосредоточусь только на c++, object pascal и java, иногда упоминая и другие языки ООП.

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

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

Классы, наследование и полиморфизм — фундаментальные свойства, требуемые от языка, претендующего называться объектно-ориентированным. (Языки, не имеющие наследования и полиморфизма, но имеющие только классы, обычно называются основанными на классах.)

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

Контроль во время компиляции и во время выполнения

Свойство: Языки программирования можно оценить по тому, насколько они строги к типам. Контроль типов включает проверку существования вызываемых методов, типов их параметров, проверку границ массивов и т.д. c++, java, и object pascal предпочитают более или менее широкий контроль во время компиляции. С++, возможно, наименее точен в этом отношении, тогда как java использует проверку типов наиболее широко. Причина этого заключается в том, что c++ обеспечивает совместимость с c-языками, которые поддерживают слабую форму проверки типов во время компиляции. Например, c и c++ считают, что все арифметические типы совместимы (хотя присвоение float целой переменной вызовет предупреждение компилятора). В object pascal и java логическое значение не целое, а символ — еще один отличный и несовместимый тип.

Тот факт, что виртуальная машина java «интерпретирует» байтовый код во время выполнения, не означает, что этот язык отказывается от проверки типов во время компиляции. Наоборот, в этом языке проверка наиболее тщательна. Другие ОО языки, такие как smalltalk и clos, наоборот, склонны большинство, если не все проверки типов осуществлять во время исполнения.

Чисто объектно-ориентированные и гибридные языки

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

Среди трех наших языков, только java является чистым ОО языком (как eiffel и smalltalk). На первый взгляд, это кажется положительной идеей. Однако она ведет к тому, что вы используете кучу статических методов и статических данных, что не так уж отличается от использования глобальных функций и данных, за исключением более сложного синтаксиса. По моему мнению, чистые ОО языки дают преимущество новичкам в ООП, потому что программист вынужден использовать (и учить) модель ООП. c++ и object pascal, наоборот, — типичные примеры гибридных языков, которые позволяют программистам использовать при необходимости традиционный подход c или pascal.

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

Простая объектная модель и ссылочно-объектная модель

Свойство: Третий элемент, по которому различаются языки ООП — их объектная модель. Некоторые традиционные языки ООП позволяют программистам создавать объекты в стеке, в куче (в хипе — heap) или в статической памяти. В этих языках переменная типа класс соответствует объекту в памяти. Так работает c++.

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

Классы, объекты и ссылки

Свойство: Так как мы обсуждаем языки ООП, то после этого введения, начнём обсуждать классы и объекты. Я надеюсь, что каждый ясно понимает разницу между этими двумя терминами. В двух словах, класс — это тип данных, а объект — экземпляр типа класс. Как нам теперь использовать объекты в языках, использующих различные объектные модели?

c++: в С++, если у нас есть класс myclass с методом mymethod, мы можем написать:

myclass obj; obj.mymethod();

и получить объект класса myclass с именем obj. Память для этого объекта обычно выделяется в стеке, и вы можете сразу начать использовать объект, как это сделано во второй строке. java: в java подобная инструкция выделяет только место для хэндла объекта, а не для самого объекта:

myclass obj; obj = new myclass(); obj.mymethod();

Прежде чем использовать объект, вы должны вызвать «new» для выделения под него памяти. Конечно, вы можете объявить и проинициализировать объект в одном предложении, избегая использования неинициализированных объектных хэндлов:

myclass obj = new myclass(); obj.mymethod();

op: object pascal использует подобный подход, но (к сожалению) требует отдельных предложений для объявления и инициализации:

var obj: myclass; begin obj := myclass.create; obj.mymethod;

Замечание: Если вам кажется, что ссылочно-объектная модель требует большей работы от программиста, обратите внимание на то, что в c++ вы часто должны использовать указатели и ссылки на объекты. Только используя указатели и ссылки, вы можете добиться полиморфизма. Ссылочно-объектная модель, наоборот, делает использование указателей подразумеваемым, скрывая от программиста сложность этого подхода. В java, в частности, официально указателей нет, хотя они там повсюду. Только программисты не имеют над ними прямого контроля, и поэтому, из соображений безопасности, не могут попасть в произвольное место памяти.

Мусорная корзина

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

c++: В c++ уничтожить объект, расположенный в стеке, довольно просто. С другой стороны, уничтожение объектов, созданных динамически, зачастую является сложной проблемой. Есть много решений, включая подсчет ссылок и «интеллектуальные» указатели, но ни один из них не даёт простого решения. Первое впечатление для c++ программистов, что использование ссылочно-объектной модели сделает ситуацию только хуже.

java: Это, конечно, не касается java, так как виртуальная машина запускает алгоритм сборки мусора (в фоновом процессе, согласно теории java; или начинает этот процесс после того, как ненадолго остановит программу, как в большинстве реальных jvm). Сбор мусора предоставляется программистам бесплатно, но он может неблагоприятно влиять на эффективность выполнения приложения. Отсутствие явно записываемых деструкторов может приводить к ошибкам в завершающем коде (см. также главу о деструкторах и методе finalize() ниже).

op: В object pascal, наоборот, нет механизма сбора мусора. Однако компоненты delphi поддерживают идею владельца (owner) объекта: владелец становится ответственным за уничтожение всех объектов, которыми он владеет. Это делает управление уничтожением объекта очень простым и прямым. delphi также использует механизм подсчёта ссылок для строк, динамических массивов и интерфейсов, освобождая объект в памяти, когда него нет больше ссылок.

Определение новых классов

Свойство: Теперь, когда мы рассмотрели, как создавать объекты для существующих классов, мы можем обратиться к определению новых классов. Класс — это просто набор методов, работающих с определёнными локальными данными.

c++: Вот c++ синтаксис определения простого класса:

class date < private: int dd; int mm; int yy; public: void init (int d, int m, int y); int day (); int month (); int year (); >;

А вот определение одного из методов:
void date::init (int d, int m, int y)
java: Синтаксис java очень похож на синтаксис c++:

class date < int dd = 1; int mm = 1; int yy = 1; public void init (int d, int m, int y) < dd = d; mm = m; yy = y;>public int day () public int month () public int year () >

Основная разница состоит в том, что код каждого метода пишется там же, где он объявляется (при этом функции не становятся вставными (inline), как в c++), и в том, что вы можете инициализировать элементы данных класса. Фактически, если вы не сделаете этого, то java проинициализирует все элементы данных за вас, используя значения по умолчанию.

op: В object pascal синтаксис определения класса другой, но похожий скорее на c++, чем на java:

type date = class private dd, mm, yy: integer; public procedure init (d, m, y: integer); function month: integer; function day: integer; function year: integer; end; procedure date.init (d, m, y: integer); begin dd := d; mm := m; yy := y; end; function date.day: integer; begin result := dd; end;

Как видите, здесь есть синтаксические отличия: методы определяются с ключевыми словами function и procedure, методы без параметров не используют скобок, методы просто объявляются внутри определения класса, тогда как определяются позже, как это обычно делается в c++. Однако pascal использует нотацию с точкой, а c++ — оператор :: (недоступный в object pascal и java). Примечание: Доступ к текущему объекту. В ОО языках методы отличаются от глобальных функций тем, что у них присутствует скрытый параметр, ссылка или указатель на объект, с которым мы работаем. Просто эта ссылка на текущий объект по-разному называется. Это this в c++ и java, self в object pascal.

Конструкторы

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

c++: В c++, так же, как и в java, имя конструктора совпадает с именем класса. Если вы не определили никакого конструктора, компилятор синтезирует конструктор по умолчанию, добавляя его к классу. В обоих этих языках вы можете завести несколько конструкторов благодаря перегрузке функций. java: Всё работает как в c++, хотя конструкторы называются также инициализаторами. Это подчеркивает тот факт, что объект создаёт виртуальная машина java, тогда как код, который вы пишете в конструкторе, просто инициализирует свежесозданный объект. (То же самое фактически происходит и в object pascal.)

op: В object pascal вы используете специальное ключевое слово constructor и можете дать конструктору любое имя. Хотя borland в delphi 4 добавила поддержку перегрузки методов, программисты всё ещё дают разным конструкторам разные имена. В object pascal у каждого класса по умолчанию есть конструктор create (наследуемый от tobject), если вы не перегрузите его конструктором с тем же именем и, возможно, другими параметрами. Этот конструктор, как мы увидим позднее, просто наследуется от общего базового класса.

Деструкторы и финализация

Свойство: Деструктор играет роль противоположную конструктору и обычно вызывается при уничтожении объекта. Если конструктор нужен большинству классов, только некоторые из них нуждаются в деструкторе. Деструктор в основном должен освободить ресурсы, зарезервированные конструктором (или другими методами во время жизни объекта). Эти ресурсы включают память, файлы, базы данных, ресурсы windows и т. д.

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

op: В object pascal деструкторы похожи на деструкторы c++. /Для деструкторов используется ключевое слово destructor (мое примечание — В.К.)/ object pascal использует стандартный виртуальный деструктор, называемый destroy. Этот деструктор вызывается стандартным методом free. Все объекты динамические, поэтому предполагается, что вы вызовете free для каждого объекта, созданного вами, если у того нет владельца, отвечающего за его уничтожение. Теоретически вы можете объявить несколько деструкторов, что имеет смысл, поскольку вы можете вызывать деструкторы в своем коде (это не делается автоматически).

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

Инкапсуляция (private и public)

Свойство: Общим элементом всех трех языков является присутствие трех спецификаторов доступа, указывающих на различные уровни инкапсуляции класса: public, protected, и private. public означает: видимый любым другим классом, protected означает: видимый производными классами, private означает: отсутствие видимости извне. В деталях, однако, есть различия. c++: В c++ вы можете использовать ключевое слово fri end для обхода инкапсуляции. Видимость по умолчанию для класса — private, для структур — public.

op: В object pascal private и protected относятся только к классам других юнитов. В терминах c++, класс является дружественным для любого другого класса, определенного в том же юните (или файле исходного кода). В delphi есть еще один модификатор доступа — published, который генерирует информацию времени выполнения (rtti) об элементах.

java: В java отличие синтаксиса в том, что модификатор доступа повторяется для каждого элемента класса. А конкретнее, по умолчанию в java используется fri end ly, это значит, что элемент видим для других классов этого же пакета (или файла исходного кода, как в op). Подобным образом, protected означает видимость для подклассов, тогда как комбинация private protected соответствует protected в c++.

Файлы, юниты и пакеты

Свойство: Важное различие между тремя языками заключается в организации исходного кода в файлах. Все три языка используют файлы в качестве стандартного механизма для запоминания исходного кода классов (в отличие от других ОО языков, таких как smalltalk), но компилятор c++, в отличие от op или java, не понимает файлов. Эти же два языка работают с идеей модулей, хотя называют их по-разному.

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

op: В object pascal каждый файл исходного кода называется unit, и он делится на две части: интерфейс и исполнение, отмечаемые соответственно ключевыми словами interface и implementation. Секция интерфейса включает в себя определения классов (с объявлениями методов), а секция исполнения должна включать в себя определения методов, объявленных в интерфейсе. Писать фактический код в секции интерфейса нельзя. Вы можете сослаться на объявления другого файла, используя предложение uses. Этим включается в компиляцию интерфейс того файла:

uses windows, form, myfile;

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

import where.myclass; import where.* // все классы

Примечание: Модули как пространство имён. Другим важным отличием java и op является их способность читать откомпилированные файлы и извлекать из них определения, как бы извлекая заголовки из скомпилированного кода. С другой стороны, для преодоления отсутствия модулей c++ включает пространство имен (namespace). В java и op, когда два имени конфликтуют, вы можете просто использовать имя модуля в качестве префикса. Это не требует дополнительной работы по определению пространств имен, а просто включено в языки.

Методы/данные класса и объекта класса

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

c++: В c++ методы и данные класса отмечаются ключевым словом static. Данные класса должны быть проинициализированы специальным объявлением, ещё одной уступкой отсутствию модулей.

op: В op допустимы только методы класса, которые отмечаются словом class. Данные класса можно заменить, так сказать, приватными глобальными переменными в секции исполнения юнита, описывающего класс. java: java использует то же слово, что и c++, static. Статические методы используются очень часто (и даже слишком) из-за отсутствия глобальных функций. Статические данные можно инициализировать прямо в объявлении класса.

Классы и наследование

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

c++: c++ использует слова public, protected, и private для определения типа наследования и чтобы спрятать наследуемые методы или данные, делая их приватными или защищёнными. Хотя публичное наследование наиболее часто используется, по умолчанию берётся приватное. Как мы увидим далее, c++ — единственный из этих трех языков, поддерживающий множественное наследование. Вот пример синтаксиса наследования:

class dog: public animal < . >;

op: object pascal при наследовании использует не ключевые слова, а специальный синтаксис, добавляя в скобках имя базового класса. Этот язык поддерживает только один тип наследования, который в c++ называется публичным. Как мы увидим позднее, классы op происходят от одного общего базового класса.

type dog = class (animal) . end;

java: java использует слово ext end s для выражения единственного типа наследования, соответствующего публичному наследованию в c++. java не поддерживает множественное наследование. Классы java тоже происходят от общего базового класса.

class dog extends animal

Примечание: Конструкторы и инициализация базового класса. И в c++, и в java у конструкторов наследующих классов сложная структура. В object pascal за инициализацию базового объекта отвечает программист. Это довольно сложный раздел, поэтому я пропустил его в этой статье. Вместо этого я сосредоточусь на общем базовом классе, множественном наследовании, интерфейсах, позднем связывании и других родственных предметах.

Предок всех классов

Свойство: В некоторых ОО языках каждый класс происходит по крайней мере от некоторого базового класса по умолчанию. Этот класс, часто называемый object, или подобно этому, обладает некоторыми основными способностями, доступными всем классам. Фактически, все другие классы в обязательном порядке ему наследуют. Этот подход является общим ещё и потому, что так первоначально делалось в smalltalk.

c++: Хотя язык c++ и не поддерживает такое свойство, многие структуры приложений базируются на нём, вводя идею общего базового класса. Пример тому — mfc с его классом coobject. Фактически это имело большой смысл вначале, когда языку не хватало шаблонов. op: Каждый класс автоматически наследует классу tobject. Так как язык не поддерживает множественное наследование, все классы формируют гигантское иерархическое дерево. Класс tobject поддерживает rtti и обладает некоторыми другими возможностями. Общей практикой является использование этого класса, когда вам нужно передать объект неизвестного типа. java: Как и в op, все классы безоговорочно наследуют классу object. И в этом языке у общего класса тоже есть некоторые ограниченные свойства и небольшая поддержка rtti.

Доступ к методам базового класса

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

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

op: В object pascal для этой цели есть специальное слово inherited. После этого слова вы можете написать имя метода базового класса или (в некоторых случаях) просто использовать это ключевое слово для доступа к соответствующему методу базового класса.

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

Совместимость подтипов

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

c++: В c++ правило совместимости подтипов справедливо только для указателей и ссылок, но не для обычных объектов. Фактически, у различных объектов разный размер, поэтому их нельзя расположить на том же месте в памяти.

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

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

Позднее связывание (и полиморфизм)

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

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

op: В object pascal позднее связывание вводится с помощью ключевых слов virtual и dynamic (разница между ними только в оптимизации). В производных классах переопределённые методы должны быть отмечены словом override (это заставляет компилятор проверять описание метода). Рациональное объяснение этой особенности op состоит в том, что разрешается больше изменений в базовом классе и предоставляет некоторый дополнительный контроль во время компиляции.

java: В java все методы используют позднее связывание, если вы не отметите их явно как final. Финальные методы не могут быть переопределены и вызываются быстрее. В java написание методов с нужной сигнатурой жизненно важно для обеспечения полиморфизма. Тот факт, что в java по умолчанию используется позднее связывание, тогда как в c++ стандартом является раннее связывание, — явный признак разного подхода этих двух языков: c++ временами жертвует ОО моделью в пользу эффективности, тогда как java — наоборот Примечание: Позднее связывание для конструкторов и деструкторов. object pascal, в отличие от других двух языков, позволяет определять виртуальные конструкторы. Все три языка поддерживают виртуальные деструкторы.

Абстрактные методы и классы

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

c++: В c++ абстрактные методы или чисто виртуальные функции получаются добавлением так называемого чистого описателя (=0) в определение метода. Абстрактные классы являются просто классами с одним или более абстрактным методом (или наследующие их). Вы не можете создать объект абстрактного класса.

op: object pascal для выделения этих методов использует ключевое слово abstract. Кроме того, абстрактными классами являются классы, имеющие или наследующие абстрактные методы. Вы можете создать объект абстрактного класса (хотя компилятор выдаст предупреждающее сообщение). Это подвергает программу риску вызвать абстрактный метод, что приведёт к генерации ошибки времени выполнения и завершению программы.

java: В java и абстрактные методы, и абстрактные классы отмечаются ключевым словом abstract (действительно, в java обязательно определять как абстрактный класс, имеющий абстрактные методы, — хотя это кажется некоторым излишеством). Производные классы, которые не переопределяют все абстрактные методы, должны быть отмечены как абстрактные. Как и в c++, нельзя создавать объекты абстрактных классов.

Множественное наследование и интерфейсы

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

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

java: java, как и object pascal, не поддерживает множественное наследование, но полностью поддерживает интерфейсы. Методы интерфейсов допускают полиморфизм, и Вы можете использовать объект, осуществляющий интерфейс, когда ожидается интерфейсный объект. Класс может наследовать или расширить один базовый класс, но может осуществить (это — ключевое слово) многочисленные интерфейсы. Хотя это не было спланировано заранее, интерфейсы java очень хорошо укладываются в модель com. Вот пример интерфейса:

public interface canfly < public void fly(); >public class bat extends animal implements canfly < public void fly() >

op: delphi 3 ввел в object pascal понятие, подобное интерфейсам java, но эти интерфейсы строго соответствуют com (хотя технически возможно использовать их в обычных не-com программах). Интерфейсы формируют иерархию, отдельную от классов, но, как и в java, класс может наследовать одному базовому классу и осуществлять различные интерфейсы. Отображение методов класса на методы интерфейсов, осуществляемых классом, является одной из наиболее сложных частей языка object pascal. delphi 4 добавляет к этой структуре возможность передать реализацию интерфейса подобъекту, делая эту технику почти такой же эффективной, как и множественное наследование.

Другие свойства

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

rtti

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

c++: Язык c++ первоначально не поддерживал rtti. Это было добавлено позже для динамического преобразования типа (dynamic_cast) и сделало доступной некоторую информацию о типе для классов. Вы можете запросить идентификацию типа для объекта, и проверить, принадлежат ли два объекта одному классу.

op: object pascal и визуальная среда поддерживает и требует много rtti. Доступен не только контроль соответствия и динамическое преобразование типов (с помощью операторов is и as). Классы генерируют расширенную rtti для своих published свойств, методов и полей. Фактически это ключевое слово управляет частью генерации rtti. Вся идея свойств, механизм потоков (файлы форм — dfm), и среда delphi, начиная с Инспектора Объектов, сильно опирается на rtti классов. У класса tobject есть (кроме прочих) методы classname и classtype. classtype возвращает переменную типа класса, объект специального типа ссылки на класс (который не является самим классом).

java: Как и в object pascal, в java тоже есть единый базовый класс, помогающий следить за информацией о классе. Безопасное преобразование типов (type-safe downcast) встроено в этот язык. Метод getclass() возвращает своего рода метакласс (объект класса, описывающего классы), и Вы можете применить функцию getname() для того, чтобы получить строку с именем класса. Вы можете также использовать оператор instanceof. java включает в себя расширенную rtti для классов или интроспекцию, которая была введена для поддержки компонентную модель javabeans.

Пример: Вот синтаксис безопасного преобразования типов на трех языках. В случае ошибки все три языка вызывают исключение:

// c++ dog* mydog = dynamic_cast (myanimal); // java dog mydog = (dog) myanimal; // object pascal dog mydog := myanimal as dog;

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

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

c++: c++ использует ключевое слово throw для генерации исключения, try для отметки охраняемого блока и catch для записи кода обработки исключения. Исключения — объекты специального класса, которые могут образовывать некоторую иерархию во всех трёх языках. c++ выполняет опустошение стека, удаление всех объектов (и вызов деструкторов) для всех объектов в стеке.

op: object pascal использует подобные ключевые слова: raise, try, и except и обладает подобными свойствами. Единственное существенное отличие состоит в том, что опустошение стека не производится, просто потому, что в стеке нет объектов. Кроме того, вы можете добавить в конце блока try слово finally, отмечая блок, который должен выполняться всегда, независимо от того, было или нет вызвано исключение. В delphi классы исключений — производные exception.

java: Использует ключевые слова c++, но ведёт себя как object pascal, включая дополнительное ключевое слово finally. (Это общее свойство всех языков со ссылочно-объектной моделью, оно включено borland также и в c++builder 3.) Присутствие алгоритма сборки мусора ограничивает использование finally в классе, который распределяет другие ресурсы, кроме памяти. Также java строже требует, чтобы все функции, которые могут вызвать исключение, описывали в соответствующем блоке, какие исключения могут быть вызваны функцией. Эти описания исключений проверяются компилятором, что является хорошим свойством, даже если оно подразумевает некоторую дополнительную работу для программиста. В классах java объекты-исключения должны наследовать классу throwable.

Шаблоны (родовое программирование)

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

c++: Только в c++ (из этих трёх языков) есть родовые классы и функции, отмечаемые ключевым словом template. Стандартный c++ включает обширную библиотеку шаблонов классов, называемую stl (Стандартная библиотека шаблонов), которая поддерживает специфический и мощный стиль программирования: родовое программирование. c++ — единственный из этих трех языков, который концентрируется на поддержке родового программирования, помимо ООП.

op: В object pascal нет шаблонов. Контейнерные классы обычно строятся как контейнеры объектов класса tobject, а затем уточняются для необходимых объектов.

java: Тоже нет шаблонов. Можно использовать (специальные) контейнеры object-ов или прибегнуть к другим трюкам.

Другие специфические свойства

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

c++: Я уже упомянул множественное наследование, виртуальные базовые классы и шаблоны. Эти свойства отсутствуют в двух других ОО языках. В c++ есть ещё перегрузка операторов, тогда как перегрузка методов присутствует также в java и была недавно добавлена в object pascal. c++ позволяет программистам перегружать и глобальные функции. Вы можете перегрузить операторы преобразования типов, написав конвертирующие методы, которые будут вызываться «за кулисами». Объектная модель c++ требует копировать конструкторы и перегружать операторы присваивания, в чем не нуждаются остальные два языка, поскольку базируются на ссылочно-объектной модели.

java: Только java поддерживает многопоточность непосредственно в языке. Объекты и методы поддерживают механизм синхронизации (с ключевым словом synchronised): два синхронизированных метода одного класса не могут выполняться одновременно. Для создания нового потока вы просто наследуете от класса thread, перегружая метод run(). Как альтернативу вы можете осуществить интерфейс runnable (что вы обычно делаете в апплетах, поддерживающих многопоточность). Мы уже обсуждали сборщик мусора. Ещё одно ключевое свойство java, конечно, идея переносимого байтового кода, но это не относится непосредственно к языку. Другое примечательное свойство — это поддержка основанных на языке компонентов, известных как javabeans и многие другие свойства, недавно добавленные в этот язык.

op: Вот некоторые специфические черты object pascal: ссылки на классы, легкие для использования указатели на методы (основа модели обработки событий) и, в частности, свойства (property). Свойство — это просто имя, скрывающее путь, которым вы получаете доступ к данным или методу. Свойство может проецироваться на прямое чтение или запись данных, а может ссылаться на метод, обеспечивающий доступ. Даже если вы меняете способ доступа к данным, вам не нужно менять вызывающий код (хотя вам нужно будет его перекомпилировать): это делает свойства очень мощным средством инкапсуляции.

Стандарты

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

c++: Стандарт ansi/iso c++ явился завершением многотрудных усилий соответствующего комитета. Большинство авторов компиляторов, кажется, пытаются подчиняться стандарту, хотя есть ещё много странностей. Теоретически развитие языка должно на этом закончиться. На практике, инициативы вроде компилятора borland c++builder, конечно, не способствуют улучшению ситуации, но многие чувствуют, что c++ очень нуждается в визуальном окружении программирования. В то же время, популярный visual c++ тянет c++ в другом направлении, например, с явным злоупотреблением макросов. (По моему личному мнению, у каждого языка есть собственная модель развития, и поэтому нет большого смысла в попытках использовать язык для того, для чего он не был предназначен.)

op: object pascal — язык-собственность, поэтому у него нет стандарта. borland лицензировал язык для пары продавцов небольших компиляторов на os/2, но это не оказало большого влияния. borland расширяет язык с каждым новым выпуском delphi.

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

Заключение: Языки и программное окружение

Как я упоминал в начале, хотя я пытался исследовать эти языки, только сравнивая синтаксические и семантические характеристики, важно рассмотреть их в соответствующем контексте. Языки нацелены на различные потребности, что означает, что они решают разные проблемы разными способами и используются в очень разных средах программирования. Хотя как языки, так и их среда копируют характеристики друг друга, они были сконструированы для разных потребностей, и в этом вы можете убедиться, сравнивая их характеристики. Цель c++ — мощность и контроль за счет сложности. Целью delphi является легкое, визуальное программирование (не отказываясь от мощности) и прочная связь с windows. Цель java — мобильность, даже за счет некоторого отказа от скорости, и распределённые приложения или исполняемое содержание www (хотя это, конечно, — не microsoft-овский взгляд на java!).

Можно определить, что успех этих трех языков зависит не от технических характеристик, которые я включил в эту статью. Финансовый статус borland, операционная система управления microsoft, популярность sun в мире internet, тот факт, что java рассматривается как anti-microsoft-овский язык, будущее броузеров Паутины и win32 api, роль и признание модели activex (из-за связанной с ней проблемой безопасности) и три уровня архитектуры delphi — вот показатели, которые могли повлиять на ваши выбор сильнее, чем технические элементы. Например, такой хороший язык как eiffel, у которого object pascal и java взяли не только некоторое вдохновение, никогда не получит реальной доли рынка, хотя он был популярен во многих университетах земного шара.

Просто имейте в виду, что «модный» становится все более частым словом в компьютерном мире. Как пользователи хотят иметь инструменты этого года (вероятно, по этой причине операционные системы называются по тому году, в котором они выпущены), программисты любят работать с последним языком программирования и первыми овладеть им. Можно наверняка утверждать, что java — не последний из языков ООП. Через несколько следующих лет найдется кто-то с новым модным языком, и все прыгнут в этот поезд, думая, что нельзя отставать, и забывая, что большинство программистов в мире всё ещё печатают на клавиатуре на добром старом cobol!

Ссылки по теме

  • Подробнее о продуктах Borland
  • Приобрести продукты компании Borland в электронном магазине ITShop.ru
  • Авторизованные курсы Borland
  • Курсы обучения по продуктам компании Borland
  • Обратиться в «Интерфейс Ltd.» за дополнительной информацией/по вопросу приобретения продуктов

Главная страница — Программные продуктыСтатьиРазработка ПО, Borland
Распечатать »
Правила публикации »
Написать редактору
Рекомендовать » Дата публикации: 22.01.2007

Композиция против наследования, паттерн Команда и разработка игр в целом

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

— Слушай, я тут придумал отличную идею игры! — гейм-дизайнер Вася был взъерошен, а глаза — красные. Я ещё попивал кофе и холиварил на Хабре, чтобы убить время перед стенд-апом. Он выжидательно посмотрел на меня, пока я закончу писать в комментариях человеку, в чем он не прав. Вася знал, что пока справедливость не восторжествует, а правда не будет защищена — смысла продолжать со мной разговор нету. Я дописал последнее предложение и перевел на него взгляд.

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

Он убежал по своим гейм-дизайнерским делам, а я — открыл IDE.

На самом деле тема «композиция против наследования», «banana-monkey problem», «проблема ромба (множественное наследование)» — частые вопросы на собеседовании в разных форматах и не зря. Неправильное использование наследования может усложнить архитектуру, а неопытные программисты не знаю, как с этим побороться и, в итоге, начинают критиковать ООП в целом и начинают писать процедурный код. Потому опытные программисты (или те, которые прочитали умные вещи в интернете) считают своим долгом спросить о таких вещах на собеседовании в самых разных формах. Универсальный ответ — «композиция лучше наследования, должна применяться и никаких оттенков серого». Тех, кто просто начитался всякого такой ответ устроит на все 100%.

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

class Character < x = 0; y = 0; moveTo (x, y) < this.x = x; this.y = y; >> class Mage extends Character < mana = 100; castSpell () < this.mana--; >> class Warrior extends Character < stamina = 100; meleeHit () < this.stamina--; >> 

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

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

Я верю, что со временем каждый программист на базе своего опыта начинает видеть очевидные проблемы, с которыми он может столкнуться. Особенно, если долго работает в команде с одним гейм-дизайнером. У нас появилось куча новых требований и фич. И наша старая «архитектура» очевидно с этим не справится.

Когда вам зададут подобную задачу на собеседовании — обязательно постараются вас подловить. Они может быть в самых разных формах — крокодилы, которые могут и плавать, и бегать. Танки, которые могут стрелять из пушки или из пулемета и так далее. Самое главное свойство таких задач — у вас есть объект, который может делать несколько разных действий. И ваше наследование никак не может справится, ведь невозможно унаследоваться и от FlyingObject и от SwimmingObject И разные объекты могут делать разные действия. В этот момент мы отказываемся от наследования и переходим к композиции:

class Character < abilities = []; addAbility (. abilities) < for (const a of abilities) < this.abilities.push(a); >return this; > getAbility (AbilityClass) < for (const a of this.abilities) < if (a instanceof AbilityClass) < return a; >> return null; > > /////////////////////////////////////// // // Тут будет список абилок, которые могут быть у персонажа // Каждая абилка может иметь свое состояние // /////////////////////////////////////// class Ability <> class HealthAbility extends Ability < health = 100; maxHealth = 100; >class MovementAbility extends Ability < x = 0; y = 0; moveTo(x, y) < this.x = x; this.y = y; >> class SpellCastAbility extends Ability < mana = 100; maxMana = 100; cast () < this.mana--; >> class MeleeFightAbility extends Ability < stamina = 100; maxStamina = 100; constructor (power) < this.power = power; >hit () < this.stamina--; >> /////////////////////////////////////// // // А тут создаются персонажи со своими абилками // /////////////////////////////////////// class CharactersFactory < createMage () < return new Character().addAbility( new MovementAbility(), new HealthAbility(), new SpellCastAbility() ); >createWarrior () < return new Character().addAbility( new MovementAbility(), new HealthAbility(), new MeleeFightAbility(3) ); >createPaladin () < return new Character().addAbility( new MovementAbility(), new HealthAbility(), new SpellCastAbility(), new MeleeFightAbility(2) ); >> 

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

createMagicTree ()

У нас пропало наследование и вместо него появилась композиция. Теперь мы создаем персонажа и перечисляем его возможные абилки. Но это не значит, что наследование — всегда плохо, просто в даном случае оно не подходит. Лучший способ понять, подходит ли наследование — ответить для себя на вопрос, какую связь оно отображает. Если эта связь «is-a», то есть вы указываете, что MeleeFightAbility — это абилка, то оно идеально подходит. Если же связь создается только потому что вы хотите добавить действие и отображает «has-a», то стоит подумать о композиции.

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

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

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

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

В подобных статьях обычно описывается только работа с моделью, потому что она абстрактная и взрослая, а «картиночки показывать» можно отдать и джуну и неважно, какая там будет архитектура. Тем не менее, наша модель должна предоставлять максимум информации для вьюшки, чтобы та могла сделать свое дело. В ГеймДеве для этого, обычно, используется паттерн «Команда». В двух словах — мы имеем стейт без логики, а любое изменение должно происходить в соответствующих командах. Это может казаться усложнением, но это дает множество преимуществ:
— Они отлично комбятся, когда одна команда вызывает другую
— Каждая команда, когда выполняется является, по сути, событием, на которое можно подписаться
— Мы их можем легко сериализировать

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

class DealDamageCommand extends Command < constructor (target, damage) < this.target = target; this.damage = damage; >execute () < const healthAbility = this.target.getAbility(HealthAbility); if (healthAbility == null) < throw new Error('NoHealthAbility'); >const resultHealth = healthAbility.health - this.damage; healthAbility.health = Math.max( 0, resultHealth ); > >

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

class MeleeHitCommand extends Command < constructor (source, target, damage) < this.source = source; this.target = target; this.damage = damage; >execute () < const fightAbility = this.source.getAbility(MeleeFightAbility); if (fightAbility == null) < throw new Error('NoFightAbility'); >this.addChildren([ new DealDamageCommand(this.target, fightAbility.power); ]); > >

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

async onMeleeHit (meleeHitCommand) < await view.drawMeleeHit( meleeHitCommand.source, meleeHitCommand.target ); >async onDealDamage (dealDamageCommand)

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

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

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

class CastSpellCommand extends Command < constructor (source, target, spell) < this.source = source; this.target = target; this.spell = spell; >execute () < const spellAbility = this.source.getAbility(SpellCastAbility); if (spellAbility == null) < throw new Error('NoSpellCastAbility'); >this.addChildren(new PayManaCommand(this.source, this.spell.manaCost)); this.addChildren(this.spell.getCommands(this.source, this.target)); > > class Spell < manaCost = 0; getCommands (source, target) < return []; >> class DamageSpell extends Spell < manaCost = 3; constructor (damageValue) < this.damageValue = damageValue; >getCommands (source, target) < return [ new DealDamageCommand(target, this.damageValue) ]; >> class HealSpell extends Spell < manaCost = 2; constructor (healValue) < this.healValue = healValue; >getCommands (source, target) < return [ new HealDamageCommand(target, this.healValue) ]; >> class VampireSpell extends Spell < manaCost = 5; constructor (value) < this.value = value; >getCommands (source, target) < return [ new DealDamageCommand(target, this.value), new HealDamageCommand(source, this.value) ]; >> 

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

— Слушай — писал гейм-дизайнер — у меня есть отличная идея.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *