Аннотация
Объектно-ориентированное программирование — это практичная и полезная методология программирования, способствующая модульному проектированию и повторному использованию программного обеспечения. Большинство объектно-ориентированных языков поддерживают абстракцию данных, препятствуя манипулированию объектом каким-либо другим образом, кроме как через заданные для него внешние операции. Однако в большинстве языков введение наследования серьезно подрывает преимущества инкапсуляции. Более того, в большинстве языков само использование наследования является глобально видимым, так что изменения в иерархию наследования нельзя вносить безопасно. В этой статье исследуется взаимосвязь между наследованием и инкапсуляцией и формулируются требования, необходимые для полной поддержки инкапсуляции в сочетании с наследованием.
Введение
Объектно-ориентированное программирование — это практичная и полезная методология программирования, способствующая модульному проектированию и повторному использованию программного обеспечения. Одна из его главных особенностей — поддержка абстракции данных, то есть возможность определять новые типы объектов, поведение которых определяется абстрактно, без ссылки на детали реализации, такие как структура данных, используемая для представления объектов.
Большинство объектно-ориентированных языков поддерживают абстракцию данных, препятствуя манипулированию объектом каким-либо другим образом, кроме как через заданные для него внешние операции. Инкапсуляция дает множество преимуществ с точки зрения облегчения понимания программ и упрощения их модификации. К сожалению, в большинстве объектно-ориентированных языков введение наследования серьезно подрывает инкапсуляцию.
В этой статье исследуется проблема инкапсуляции и ее поддержки в объектно-ориентированных языках. Мы начнем с рассмотрения понятий инкапсуляции и абстракции данных в том виде, как они реализованы в большинстве объектно-ориентированных языков. Затем мы рассмотрим понятие наследования и покажем, в чем модели наследования в таких популярных объектно-ориентированных языках, как Smalltalk [Goldberg83], Flavors [Moon86] и Objective-C [Cox84], оказываются недостаточными с точки зрения поддержки инкапсуляции. Наконец, мы исследуем требования, необходимые для полной поддержки инкапсуляции в сочетании с наследованием.
Объектно-ориентированное программирование
Объектно-ориентированное программирование — это методология программирования, основанная на следующих ключевых характеристиках:
Проектировщики определяют новые классы (или типы) объектов.
Для объектов определены операции.
Вызовы работают с объектами разных типов (то есть операции являются обобщенными).
Определения классов совместно используют общие компоненты с помощью наследования.
В этой статье мы используем следующую модель и терминологию: объектно-ориентированный язык программирования позволяет проектировщику определять новые классы объектов. Каждый объект является экземпляром одного класса. Объект представлен набором переменных экземпляра, определенных классом. Каждый класс определяет набор именованных операций, которые можно выполнять над экземплярами этого класса. Операции реализуются процедурами, которые могут обращаться к переменным экземпляра целевого объекта и присваивать им значения. Наследование может использоваться для определения класса на основе одного или более других классов. Если класс c (напрямую) наследует от класса p, мы говорим, что p является родителем c, а c — дочерним классом p. Термины предок и потомок используются в общепринятом смысле [1].
Инкапсуляция
Инкапсуляция — это техника минимизации взаимозависимостей между отдельно написанными модулями путем определения строгих внешних интерфейсов. Внешний интерфейс модуля служит контрактом между модулем и его клиентами и, следовательно, между проектировщиком модуля и другими проектировщиками. Если клиенты зависят только от внешнего интерфейса, модуль можно переписать, не затрагивая клиентов, при условии, что новая реализация поддерживает тот же (или обратно совместимый) внешний интерфейс. Таким образом, последствия совместимых изменений могут быть ограничены.
Модуль инкапсулирован, если по правилам языка программирования доступ клиентов к этому модулю возможен только через его заданный внешний интерфейс. Таким образом, инкапсуляция гарантирует проектировщикам, что совместимые изменения можно вносить безопасно, а это облегчает развитие и сопровождение программы. Эти преимущества особенно важны для больших систем и долгоживущих данных.
Чтобы максимально использовать преимущества инкапсуляции, следует сводить к минимуму раскрытие деталей реализации во внешних интерфейсах. Язык программирования поддерживает инкапсуляцию в той мере, в какой он позволяет определять минимальные внешние интерфейсы и обеспечивать их соблюдение [2]. Эту поддержку можно охарактеризовать тем, какие виды изменений можно безопасно вносить в реализацию модуля. Например, одной из характеристик объектно-ориентированного языка является то, позволяет ли он проектировщику определить класс так, чтобы его переменные экземпляра можно было переименовать, не затрагивая клиентов.
Абстракция данных
Абстракция данных — полезная форма модульного программирования. Поведение абстрактного объекта данных полностью определяется набором абстрактных операций, определенных над объектом; пользователю объекта не требуется понимать, как эти операции реализованы или как этот объект представлен.
В большинстве объектно-ориентированных языков программирования объекты — это абстрактные объекты данных. Внешний интерфейс объекта — это набор операций, определенных над ним. Большинство объектно-ориентированных языков ограничивают внешний доступ к объекту вызовом операций, определенных над объектом, и, следовательно, поддерживают инкапсуляцию [3]. Изменения в представлении объекта или реализации его операций можно вносить, не затрагивая пользователей объекта, при условии, что внешне видимое поведение операций остается неизменным.
Определение класса — это модуль с собственным внешним интерфейсом. Как минимум, этот интерфейс описывает, как создаются экземпляры этого класса, включая любые параметры создания. Во многих языках класс сам является объектом, и его внешний интерфейс состоит из набора операций, включая операции по созданию экземпляров.
Подводя итог, в большинстве объектно-ориентированных языков программирования объекты (включая объекты классов) — это инкапсулированные модули, внешний интерфейс которых состоит из набора операций; изменения реализации объекта, сохраняющие внешний интерфейс, не затрагивают код за пределами определения класса [4]. Если бы не наследование, на этом можно было бы и закончить.
Наследование
Наследование усложняет ситуацию, вводя новую категорию клиентов класса. В дополнение к клиентам, просто создающим экземпляры объектов класса и выполняющим операции над ними, существуют другие клиенты — определения классов, наследующие от класса. Чтобы полностью охарактеризовать объектно-ориентированный язык, мы должны рассмотреть, какой внешний интерфейс класс предоставляет своим дочерним классам. Этот внешний интерфейс столь же важен, как и внешний интерфейс, предоставляемый пользователям объектов, поскольку он служит контрактом между классом и его дочерними классами и тем самым определяет, насколько безопасно проектировщик может вносить изменения в класс.
Зачастую проектировщику требуется определить разные внешние интерфейсы для этих двух категорий клиентов. Большинство объектно-ориентированных языков удовлетворяют эту потребность, предоставляя дочерним классам значительно менее ограниченный внешний интерфейс. Тем самым преимущества инкапсуляции, обычно связываемые с объектно-ориентированными языками, в значительной степени утрачиваются, поскольку у проектировщика класса остается меньше свободы для внесения совместимых изменений. Это было бы не так важно, если бы наследование использовали только отдельные проектировщики или небольшие группы, разрабатывающие семейства родственных классов. Однако системные проектировщики сочли целесообразным создавать классы, от которых наследует множество классов, определяемых независимыми проектировщиками приложений (наглядным примером служит класс window в оконной системе Lisp-машины [Weinreb81]); таким проектировщикам необходим четко определенный внешний интерфейс, обеспечивающий защиту и позволяющий гибко изменять реализацию.
Мы начнем рассматривать наследование с вопроса доступа к унаследованным переменным экземпляра.
Наследование переменных экземпляра
В большинстве объектно-ориентированных языков код класса может напрямую обращаться ко всем переменным экземпляра своих объектов, даже к тем, что были определены классом-предком. Таким образом, проектировщик класса получает полный доступ к представлению, определенному классом-предком.
Это свойство не меняет внешний интерфейс отдельных объектов, поскольку переменные экземпляра объекта по-прежнему доступны только для операций, определенных над этим объектом. Однако оно меняет внешний интерфейс класса (в восприятии его потомков), который теперь (неявно) включает переменные экземпляра.
Предоставление доступа к переменным экземпляра, определенным классами-предками, подрывает описанные выше свойства инкапсуляции: поскольку переменные экземпляра доступны клиентам класса, они (неявно) являются частью контракта между проектировщиком класса и проектировщиками классов-потомков. Тем самым свобода проектировщика изменять реализацию класса ограничивается. Он больше не может безопасно переименовать, удалить или переопределить назначение переменной экземпляра без риска неблагоприятно повлиять на классы-потомки, зависящие от этой переменной.
Подводя итог, предоставление прямого доступа к унаследованным переменным экземпляра ослабляет одно из главных преимуществ объектно-ориентированного программирования — свободу проектировщика менять представление класса, не затрагивая его клиентов.
Безопасный доступ к унаследованным переменным
Чтобы сохранить все преимущества инкапсуляции, внешние интерфейсы определения класса не должны включать переменные экземпляра. Переменные экземпляра защищены от прямого доступа со стороны пользователей объекта за счет того, что для доступа к ним требуется использовать операции. Ту же технику можно применить, чтобы предотвратить прямой доступ со стороны классов-потомков.
Чтобы классы-потомки могли эффективно использовать операции доступа к переменным экземпляра, требуется дополнительная поддержка со стороны языка. Обычный вызов операции на self [5] здесь не подходит, поскольку может привести к вызову не той операции (если она переопределена классом или одним из его потомков). Вместо этого нужен способ прямого вызова операции (над self), определенной родительским классом. Smalltalk предоставляет такой механизм в контексте одиночного наследования: псевдопеременную super. Вызов операции на super аналогичен вызову операции на self, за исключением того, что поиск вызываемой операции начинается с родителя класса, в котором происходит этот вызов, а не с класса self. Аналогичная возможность с использованием составных имен (родитель и операция) для указания нужной операции реализована в CommonObjects [Snyder85a], Trellis/Owl [Schaffert86], расширенном Smalltalk [6] [Borning82] и C++ [Stroustrup86]. Поскольку поиск нужной операции начинается со статически известного класса, вызов операции таким способом может быть эффективнее обычного; в некоторых реализациях поиск во время выполнения вообще не требуется.
Существует несколько возможных возражений против использования операций для доступа к унаследованным переменным экземпляра. Большинство из этих возражений также применимы к обычному случаю использования операций для доступа к переменным экземпляра объекта, и их можно разрешить таким же образом: синтаксические сокращения позволяют наследующему классу использовать обычный синтаксис обращения к переменной для вызова этих операций [Snyder85a] [7]. Встраивание кода позволяет избежать накладных расходов на вызов процедуры (ценой необходимости перекомпиляции клиента при несовместимом изменении класса-предка).
Наиболее серьезное возражение против этого решения заключается в том, что оно требует содействия со стороны проектировщика класса-предка, определяющего переменную экземпляра (а также проектировщиков всех промежуточных предков), поскольку доступ к унаследованной переменной экземпляра невозможен, если не были предоставлены соответствующие операции. Мы утверждаем, что так и должно быть. Если вам (как проектировщику класса) нужен доступ к унаследованной переменной экземпляра, а соответствующие операции не определены, правильным решением будет договориться с проектировщиком (или проектировщиками) класса-предка (или классов-предков) о предоставлении этих операций [8].
Одна из проблем этого сценария заключается в том, что операции над переменными экземпляра, определенные в классе для использования его потомками, не обязательно подходят пользователям экземпляров класса, однако они общедоступны. Удобное решение (предоставленное в Trellis/Owl) состоит в том, чтобы объявить некоторые операции доступными только для прямого вызова (на self) классами-потомками, но не являющиеся частью внешнего интерфейса объектов этого класса. Это понятие обобщает «приватные операции», предоставляемые различными языками, включая Trellis/Owl и C++. Оно дает проектировщику возможность тонкого контроля над внешними интерфейсами, предоставляемыми двум категориям клиентов класса.
Если переменные экземпляра не являются частью внешнего интерфейса родительского класса, то неправомерно сливать унаследованные переменные экземпляра с переменными экземпляра, определенными в классе локально (как это сделано во Flavors). Очевидно, что если локальная переменная экземпляра и унаследованная переменная экземпляра с одним и тем же именем превращаются в одну переменную экземпляра, то изменение имени переменной экземпляра в родителе, скорее всего, изменит поведение дочернего класса. Также недопустимо выдавать ошибку, если класс определяет переменную экземпляра с тем же именем, что и унаследованная переменная экземпляра (как это сделано в Smalltalk), поскольку изменение имени переменной экземпляра может сделать класс-потомок некорректным. Аналогичные возражения применимы к слиянию переменных экземпляра, определенных несколькими родителями (как это сделано во Flavors), или к об ошибке, если несколько родителей определяют переменные экземпляра с одним и тем же именем (как это сделано в расширенном Smalltalk).
Видимость наследования
Более глубокий вопрос, который ставит наследование, заключается в том, должно ли само использование наследования быть частью внешнего интерфейса (класса или объектов). Иными словами, должны ли клиенты класса (обязательно) иметь возможность выяснить, определен ли класс с использованием наследования?
Если использование наследования является частью внешнего интерфейса, то изменения в том, как определение класса использует наследование, могут затронуть код клиентов. Например, представим класс, определенный с использованием наследования. Предположим, что проектировщик этого класса решил, что то же поведение можно реализовать более эффективно, написав совершенно новую реализацию, не используя ранее унаследованный класс. Если прежнее использование наследования было видимо клиентам, то такая переработка реализации может потребовать изменений в клиентах. Возможность безопасно вносить изменения в иерархию наследования необходима для поддержки эволюции больших систем и долгоживущих данных.
Этот проблема поднимает фундаментальный вопрос о смысле наследования. Можно рассматривать наследование как частное решение проектировщика «повторно использовать» код, потому что это полезно; такое решение должно быть легко изменить. Или же можно рассматривать наследование как публичное заявление о том, что объекты дочернего класса подчиняются семантике родительского класса, и тогда дочерний класс лишь конкретизирует или уточняет родительский класс. Этот вопрос рассматривается в контексте представления знаний в [Brachman85].
Мы считаем, что возможность использовать наследование, не включая его в публичный интерфейс класса, ценна, и проанализируем, как существующие объектно-ориентированные языки поддерживают эту возможность. Мы начнем с рассмотрения случая одиночного наследования, то есть когда наследующий класс (дочерний) напрямую наследует от одного класса (родителя). Множественное наследование создает дополнительные проблемы и будет обсуждаться ниже.
Субтипирование
Один из способов, которым наследование может проявляться во внешнем интерфейсе класса, — это субтипирование, то есть правила, по которым объекты одного типа (класса) считаются допустимыми в контекстах, ожидающих другой тип (класс). В статически типизированных языках, таких как Trellis/Owl, Simula [Dahl66] и C++, правила субтипирования критически важны, поскольку они определяют корректность программ. В динамически типизированных языках, подобных Common Lisp [Steele84], правила субтипирования влияют на результаты предикатов типа.
Многие объектно-ориентированные языки связывают субтипирование и наследование. Например, в Trellis/Owl, Simula и C++ класс x является подтипом класса y тогда и только тогда, когда x — потомок y. Если проектировщик переработает класс x так, чтобы он наследовал от класса z вместо y, то программы-клиенты, предполагающие, что x является подтипом y, перестанут быть корректными, даже если предполагаемый внешний интерфейс x (его операции) не изменился. Таким образом, использование наследования раскрывается через правила субтипирования [9].
Чтобы избежать этой проблемы, субтипирование не должно быть привязано к наследованию. Напротив, субтипирование должно основываться на поведении объектов. Если экземпляры класса x соответствуют внешнему интерфейсу класса y, то x должен быть подтипом y. Приведенный выше пример показывает, что иерархия реализации не обязана совпадать с иерархией типов (определяемой поведением объектов) [Canning85]. В этом примере stack наследует от deque, но не является подтипом deque, а deque является подтипом stack, хотя и не наследует от stack.
Поведенческое субтипирование невозможно вывести без формальных семантических спецификаций поведения. В отсутствие таких спецификаций можно вывести субтипирование, основываясь исключительно на синтаксических внешних интерфейсах (то есть на именах операций) [Cardelli84]. В качестве альтернативы (или дополнения) можно позволить проектировщику класса указывать, подтипом каких других классов он является. По умолчанию класс может являться подтипом каждого из своих родителей. Однако, как показано в примере, проектировщик должен иметь возможность указать, что класс не является подтипом родителя или что класс является подтипом класса, не связанного с ним родством (не являющегося его родителем). Первый случай возникает, когда поведение объектов несовместимо с интерфейсом родительских объектов. Второй случай возникает, когда класс поддерживает внешний интерфейс другого класса, не используя его реализацию.
CommonObjects, объектно-ориентированное расширение Common Lisp, служит примером языка, позволяющего проектировщику задавать иерархию типов независимо от иерархии наследования. В Common Lisp проверка типов определена через предикат typep, который принимает два аргумента — объект и спецификацию типа — и возвращает true тогда и только тогда, когда объект принадлежит указанному типу. Классы CommonObjects интегрированы в систему типов Common Lisp таким образом, что если переданный в typep объект является экземпляром класса, а спецификация типа — именем класса, то для определения результата typep над объектом выполняется операция :typep (со спецификацией типа в качестве аргумента). Проектировщик класса может написать для этой операции произвольный предикат, хотя предикат по умолчанию уже предоставлен [10].
Следующие операции будут использоваться в упомянутом примере:
(define-method (stack :typep) (the-type)
(equal the-type ‘stack'))
(define-method (deque :typep) (the-type)
(or (equal the-type 'deque')
(equal the-type 'stack')
))Этого примитивного механизма достаточно, однако он неудобен и ненадежен. Более удачное решение явным образом представляло бы отношения субтипирования и гарантировало транзитивность.
Видимость атрибутов
Если использование наследования не является частью внешнего интерфейса класса, то клиенты класса не могут напрямую ссылаться на предков класса. В частности, класс может ссылаться на своих родителей, но не на более далеких предков.
Как упоминалось выше, полезно, чтобы класс мог вызывать операцию, определенную родителем. В большинстве языков, поддерживающих эту возможность, нужная операция задается составным именем, включающим имя родительского класса и имя операции. Этого решения достаточно: чтобы получить доступ к операции более далекого предка, не нарушая инкапсуляции, эта операция должна быть передана через всех промежуточных предков, включая как минимум одного родителя. Trellis/Owl и расширенный Smalltalk позволяют классу напрямую обращаться к операции непрямого предка. Как следствие, в этих языках имена классов-предков неизбежно оказываются частью внешнего интерфейса класса.
Множественное наследование
Множественное наследование означает, что класс может иметь более одного родителя. Можно рассматривать некоторый класс (назовем его корневым классом) как образующий корень ориентированного ациклического графа, который мы будем называть графом наследования; в этом графе каждый класс является узлом, и от каждого класса к каждому из его родителей идет дуга. На рис. 1 показан граф наследования с множественным наследованием. В этом примере класс x — корневой класс. Класс x наследует от классов y1 и y2, а y1 и y2 оба наследуют от класса z.

Существуют две распространенные стратегии работы с множественным наследованием. Первая стратегия пытается работать напрямую с графом наследования. Вторая стратегия сначала преобразует граф в линейную цепочку, а затем работает с этой цепочкой, используя правила одиночного наследования.
Графо-ориентированные решения
Trellis/Owl и расширенный Smalltalk — примеры объектно-ориентированных языков, семантика которых напрямую моделирует граф наследования. В этих языках операции наследуются по графу наследования, пока они не переопределены в классе. Если класс наследует операции с одинаковым именем более чем от одного родителя, этот конфликт надо как-то разрешить. Например, можно переопределить операцию в дочернем классе; новое определение может вызывать операции, определенные в родительских классах. Чтобы вызвать все определения операции в графе наследования (например, все операции display), каждый класс может определить операцию, которая вызывает операцию на каждом из родителей и затем выполняет любое локальное вычисление, выполняет обход в глубину графа наследования. Расширенный Smalltalk предоставляет удобный синтаксис для вызова операции на каждом из родителей класса.
В этих языках интересные вопросы возникают, когда граф не является деревом, то есть когда один и тот же класс достижим из корневого класса по нескольким путям (как на рис. 1). Trellis/Owl и расширенный Smalltalk применяют схожие способы разрешения конфликта, когда класс пытается наследовать операцию от более чем одного родителя: наследование операций с одинаковым именем от двух или более родителей считается ошибкой [11], но только если эти операции действительно различны. Иными словами, если одна и та же операция (из одного и того же класса) наследуется классом по разным путям в графе наследования, это не является ошибкой.
Это исключение для одинаковых операций продиктовано соображениями удобства, поскольку конфликты операций неизбежны, когда в графе наследования имеется слияние. Хотя на первый взгляд оно может показаться безобидным, это исключение заставляет делать использование наследования частью внешнего интерфейса.
Чтобы использование наследования не обязательно становилось частью внешнего интерфейса класса, приходится отказываться от удобного исключения для одинаковых операций из правила, регулирующего конфликты операций. Альтернативный метод разрешения конфликтов, позволяющий избежать этой проблемы, состоит в том, чтобы выбирать операцию у «первого» родителя, который ее определяет (на основе текстового порядка, в котором перечислены родители); этот метод нежелателен, так как не предупреждает проектировщика о непреднамеренных конфликтах.
Другая особенность графо-ориентированного решения состоит в том, что для любого класса-предка определяется только один набор переменных экземпляра, независимо от количества путей, по которым этот класс достижим в графе наследования. Например, на рис. 1 экземпляр x содержит по одному экземпляру каждой переменной экземпляра, определенной в z, а не по два.
Хотя такой результат обычно желателен, он порождает потенциальные проблемы. Например, использование обхода в глубину (как описано выше) на графе наследования со слиянием приведет к тому, что некоторые операции будут вызываться более одного раза на одном и том же наборе переменных экземпляра. В результате проектировщик не может изменить использование наследования внутри класса, не рискуя нарушить работу какого-либо класса-потомка.

Рассмотрим структуру, показанную на рис. 2. Предположим, что операция o определена в классах z, y2 и x, причем определение o в классе x вызывает операцию o на обоих родителях. Теперь предположим, что проектировщик класса y2 решает переработать y2 так, чтобы он наследовал от z, сохраняя при этом внешнее поведение объектов класса y2; допустим, что класс y2 либо унаследует o от z, либо изменит свое определение так, чтобы вызывать o, определенную в z. Теперь операция o в классе x приведет к тому, что операция o из класса z будет вызвана дважды на одном и том же наборе переменных экземпляра. Если o в z имеет побочные эффекты, результат может оказаться неприемлемым. Таким образом, в этом примере изменение использования наследования классом (y2) нарушает работу одного из его клиентов (x), даже несмотря на то, что операции его объектов имеют то же внешнее поведение; использование наследования, следовательно, (неявно) входит во внешний интерфейс класса y2.
Линейные решения
Вторая стратегия работы с множественным наследованием состоит в том, чтобы сначала преобразовать граф наследования в линейную цепочку без дубликатов, а затем рассматривать результат как одиночное наследование. Эта стратегия используется во Flavors (в недавно пересмотренной версии) [Moon86] и CommonLoops [Bobrow86]. В этих языках применяются схожие алгоритмы построения линейного порядка, сохраняющего порядок вдоль каждого пути в графе наследования (класс никогда не оказывается после одного из своих предков). Flavors также пытается сохранить относительный порядок родителей класса (первый родитель класса никогда не оказывается после второго и т. д.); если не существует линейного порядка, удовлетворяющего всем этим ограничениям, выдается ошибка.
Хотя относительный порядок класса и его предков в линейном порядке сохраняется, неродственные классы могут быть вставлены между классом и его родителем. Например, граф наследования, показанный на рис. 1, будет преобразован в цепочку, показанную на рис. 3. Обратите внимание, что класс y2 теперь находится между классом y1 и его классом-родителем z. Таким образом, хотя эти языки рассматривают вычисленную цепочку наследования как одиночное наследование, она обладает тем необычным свойством, что «фактическим родителем» класса c может оказаться класс, о котором проектировщик класса c не имеет абсолютно никакого представления.

Одна из проблем этого решения состоит в том, что в случае конфликта операций (когда два или более родителя определяют одну и ту же операцию) будет выбрана одна из них, даже если однозначно «лучшего» выбора не существует. Рассмотрим приведенный выше пример: если операция o определена только классами y1 и y2, то класс x унаследует операцию, определенную классом y1. Этот выбор подразумевает, что порядок родителей в определении класса значим. Хотя подобные конфликты, скорее всего, непреднамеренны, никакого предупреждения не выдается.
Другая проблема связана со способностью класса надежно взаимодействовать со своими «настоящими» родителями. CommonLoops позволяет классу вызывать своего «фактического родителя» (своего родителя в вычисленной цепочке наследования), используя обозначение, подобное super в Smalltalk. Flavors предоставляет декларативный механизм под названием «комбинация методов», который, по существу, эквивалентен этому. Эти механизмы позволяют избежать проблемы непреднамеренного многократного вызова операций, возможной при использовании графо-ориентированных решений. Однако они затрудняют для класса надежную организацию приватного взаимодействия с родителем, учитывая чередование классов, которое может возникнуть при вычислении цепочки наследования.
В приведенном выше примере предположим, что класс y1 хочет наладить приватное взаимодействие со своим родителем z; а именно, он хочет вызывать (публичные) операции, определенные в z, так, чтобы на него не влияли переопределения этих операций в других классах. Такое приватное взаимодействие легко организовать в графо-ориентированных языках (Trellis/Owl и расширенный Smalltalk) путем прямого обращения к операциям класса z. Однако, используя super или его эквивалент, класс y1 может напрямую вызывать лишь операции своего «фактического родителя», которым в данном случае является y2; если окажется, что y2 переопределил данную операцию, будет вызвана «неправильная» (с точки зрения y1) операция. Этот пример показывает, что для корректного использования y1 в качестве родителя необходимо понимать детали его внутреннего устройства, касающиеся того, как он использует своего родителя z.
В отличие от графо-ориентированных языков, ни CommonLoops, ни Flavors не позволяют классу задать операцию по имени определяющего родителя. Добавление такой возможности позволило бы моделировать семантику графо-ориентированных языков. Однако ее использование было бы чревато ошибками, поскольку смешанное применение двух разных стилей (линейного и графового), вероятно, приводило бы к нежелательным результатам.
Решения на основе деревьев
В CommonObjects [Snyder85a] мы исследуем третью стратегию работы с множественным наследованием, позволяющую избежать проблем графо-ориентированных и линейных решений. Подобно графо-ориентированным решениям Trellis/Owl и расширенного Smalltalk, семантика CommonObjects моделирует граф наследования. Однако есть два ключевых различия: 1) Попытка унаследовать операцию от более чем одного родителя всегда является ошибкой, вне зависимости от источника (или источников) операции. Это изменение устраняет описанную выше «утечку». 2) Граф наследования преобразуется в дерево путем дублирования узлов. Иными словами, каждый родитель каждого класса определяет полностью отдельный набор унаследованных переменных экземпляра; если класс достижим в графе наследования несколькими путями, для каждого такого пути будет создан отдельный набор переменных экземпляра. Например, на рис. 1 экземпляр x содержит по два экземпляра каждой переменной экземпляра, определенной в z. Это изменение позволяет избежать ситуаций, в которых операция случайно вызывается несколько раз на одном и том же наборе переменных экземпляра или в которых два класса конфликтуют при использовании унаследованного класса.
Решение на основе дерева, используемое в CommonObjects, позволяет избежать раскрытия использования наследования, но радикально меняет семантику графов наследования с общими узлами. Приложения на других языках, использующих общих предков, почти наверняка пришлось бы перерабатывать для CommonObjects (и наоборот). Общие предки, как правило, появляются при наследовании нескольких «базовых» классов, где «базовым» считается класс, определяющий «полный» набор операций и обычно предназначенный для создания экземпляров. Альтернатива состоит в том, чтобы наследовать один «базовый» класс и один или более классов-«примесей», где класс-«примесь» [Weinreb81] определяет набор операций, связанных с одной конкретной особенностью, и спроектирован исключительно для наследования («примешивания» к классу). Применение этой стратегии будет стимулировать проектировщиков к созданию большего числа более общих классов-«примесей». Необходимы дальнейшие эксперименты для определения практической применимости этого альтернативного стиля программирования.
Наследование в ThingLab [Borning81] использует модифицированное решение на основе дерева — оно отличается от CommonObjects в двух важных отношениях: в ThingLab каждый родитель (называемый part) является самостоятельным объектом, и возможность слияния ThingLab может явным образом вызывать слияние в иерархии наследования.
Выводы
Мы выявили две области, в которых поддержка инкапсуляции в большинстве объектно-ориентированных языков недостаточна. Первая область — это инкапсуляция переменных экземпляра, важная особенность большинства объектно-ориентированных языков. Многие популярные объектно-ориентированные языки (например, Smalltalk, Flavors и Objective-C) предоставляют классам-потомкам свободный доступ к унаследованным переменным экземпляра, тем самым лишая проектировщика свободы совместимым образом изменять представление класса, не затрагивая клиентов. Отрадно, что несколько более новых языков (CommonObjects, Trellis/Owl и C++) исправляют этот недостаток, ограничивая доступ к унаследованным переменным экземпляра. Там, где доступ к унаследованным переменным экземпляра необходим, он должен предоставляться в форме операций. Нужна поддержка со стороны языка, позволяющая классам напрямую вызывать операции родителя (над self) и позволяющая делать такие операции доступными указанным способом, но не через обычный вызов операции. Кроме того, неявное слияние переменных экземпляра по имени, определенных в разных классах, не согласуется с целями инкапсуляции.
Другая область — это видимость самого наследования. Наследование — полезный механизм повторного использования кода; решение проектировщика использовать наследование для этой цели должно быть частным и легко изменяемым. Чтобы использование наследования могло оставаться частным, класс должен иметь возможность исключать операции, определенные в его родителях. Класс не должен ссылаться на предков, отличных от своих родителей. Язык не может определять отношение субтипирования, основываясь исключительно на наследовании. О конфликтах при наследовании операций от нескольких родителей должно сообщаться независимо от источника (источников) операции (операций).
Две общеупотребительные модели множественного наследования требуют знания о том, как класс использует наследование, чтобы понять, как корректно унаследовать этот класс. CommonObjects предоставляет альтернативную форму множественного наследования, поддерживающую инкапсулированные определения классов, внешний интерфейс которых полностью определяется набором операций; необходимы дальнейшие эксперименты для оценки и доработки этого стиля программирования.
Задача проектировщиков языков — предоставить средства, при помощи которых проектировщик класса может выразить интерфейс для наследующих клиентов, раскрывающий минимум информации, необходимой для корректного использования класса.
Примечания
[1] Мы избегаем традиционных терминов подкласс и суперкласс, поскольку они часто используются неоднозначно, означая как прямое, так и косвенное наследование.
[2] Поддержку инкапсуляции, предоставляемую языком, всегда можно улучшить, расширив его дополнительными объявлениями (например, в форме машиночитаемых комментариев) и написав программы, проверяющие, что клиенты соблюдают эти объявления. Фактически такой подход приводит к определению нового языка (причем без изменения существующего компилятора); исходный язык не стал от этого менее сложным.
[3] Большинство практичных языков предоставляют способы обхода строгой инкапсуляции для поддержки отладки и создания сред программирования. Например, в Smalltalk операции instVarAt: и instVarAt:put: обеспечивают доступ (по числовому смещению) к любой именованной переменной экземпляра любого объекта [Goldberg83, p. 247]. Поскольку эти способы обхода, как правило, не используются в обычном программировании, мы не учитываем их в этом анализе.
[4] В C++ [Stroustrup86] операция, выполняемая над одним объектом класса, может получать доступ к внутренним данным других объектов этого класса; таким образом, инкапсулированным модулем является набор объектов класса, а не каждый отдельный объект. В этой статье мы не учитываем это различие, поскольку оно не влияет на наш анализ.
[5] В Smalltalk и многих его производных self используется внутри операции для ссылки на объект, над которым эта операция выполняется. В других языках для той же цели служат такие имена, как me и this.
[6] Термин «расширенный Smalltalk» относится к определенному Борнингом и Инголлсом расширению Smalltalk, обеспечивающему множественное наследование.
[7] Этот вариант является частным случаем общей конструкции, которую мы называем псевдопеременными [Snyder88b]. Псевдопеременная выглядит как обычная лексическая переменная, однако результатом обращения к ней или присваивания ей значения является выполнение произвольного (возможно, заданного пользователем) кода.
[8] Среда разработки программного обеспечения может позволить вам обойти это ограничение, например, временно определив эти операции самостоятельно. Однако если вы всерьез намерены использовать чужой код, такое согласование необходимо.
[9] Следует отметить, что хотя отождествление субтипирования классов с наследованием и влечет потерю гибкости, оно дает значительные преимущества в реализации в статически типизированных языках, подобных упомянутым выше, где накладные расходы на выполнение операции не превышают одного дополнительного уровня косвенности.
[10] Smalltalk определяет над всеми объектами операцию isKindOf:, проверяющую, является ли объект экземпляром класса или одного из его потомков; однако неясно, можно ли переопределить эту операцию с пользой.
[11] Trellis/Owl выдает ошибку на этапе компиляции; расширенный Smalltalk создает для класса операцию, которая выдает ошибку при вызове.
Источники
[Bobrow86] Bobrow В. et al. CommonLoops: Merging Common Lisp and Object-Oriented Programming // OOPSLA '86: Conference Proceedings on Object-Oriented Programming Systems, Languages and Applications. Portland, Oregon, September 1986.
[Borning81] Borning A. The Programming Language Aspects of ThingLab, a Constraint-Oriented Simulation Laboratory // ACM Transactions on Programming Languages and Systems, vol. 3, № 4, October 1981.
[Borning82] Borning A., lngalls D. Multiple Inheritance in Smalltalk-80 // Proceedings AAAI, 1982.
[Borning85] Brachman R.J. “I Lied about the Trees” — Or, Defaults and Definitions in Knowledge Representation // AI Magazine, vol. 6, № 3, Fall 1985.
[Canning85] Питер Кеннинг. Личное общение.
[Cardelli84] Cardelli L. The Semantics of Multiple Inheritance // Proceedings of the Conference on the Semantics of Datatypes. Springer-Verlag Lecture Notes in Computer Science, June 1984.
[Cox84] Cox B. Message/Object Programming: An Evolutionary Change in Programming Technology // IEEE Software, vol. 1, № 1, June 1984.
[Dahl66] Dahl O.-J., Nygaard K. SIMULA – An ALGOL-based Simulation Language // Communications of the ACM, vol. 9, № 9, September 1966.
[Goldberg83] Goldberg A., Robson D. Smalltalk-80: The Language and its Implementation. Reading: Addison-Wesley, 1983.
[Moon86] Moon D.A. Object-Oriented Programming with Flavors // OOPSLA '86: Conference Proceedings on Object-Oriented Programming Systems, Languages and Applications. Portland, Oregon, September 1986.
[Schaffert86] Schaffert C. et al. An Introduction to Trellis/Owl // OOPSLA '86: Conference Proceedings on Object-Oriented Programming Systems, Languages and Applications. Portland, Oregon, September 1986.
[Snyder85a] Snyder A. Object-Oriented Programming for Common Lisp // Report ATC-85-1, Software Technology Laboratory, Hewlett-Packard Laboratories, Palo Alto, California, 1985.
[Snyder85b] Snyder A., Creech M., Kempf J. A Common Lisp Objects Implementation Kernel // Report STL-85-08, Software Technology Laboratory, Hewlett-Packard Laboratories, Palo Alto, California, 1985.
[Steele84] Steele G.L.Jr. Common Lisp — The Language. Digital Press, 1984.
[Stroustrup86] Страуструп Б. Язык программирования С++. М.: Бином, 2006.
[Weinreb81] Weinreb D., Moon D. Lisp Machine Manual. Symbolics, Inc., 1981.























