Ребекка Вирфс-Брок, Брайан Уилкерсон «Объектно-ориентированное проектирование: ответственностно-ориентированный подход»

от автора

Аннотация

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

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

Введение

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

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

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

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

Дата-ориентированное проектирование

Дата-ориентированное проектирование — результат адаптации методов проектирования абстрактных типов данных к объектно-ориентированному программированию. Адаптация оказалась простой, поскольку классы напоминают абстрактные типы данных.

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

Прежде чем описать дата-ориентированное проектирование, кратко рассмотрим проектирование абстрактных типов данных.

Проектирование абстрактных типов данных

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

  • Какие данные охватывает этот тип?

  • Какие алгоритмы можно применить к этим данным?

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

Определение дата-ориентированного проектирования

В дата-ориентированном проектировании объекты проектируются на основе следующих вопросов:

  • Какую структуру представляет этот объект?

  • Какие операции могут быть выполнены этим объектом?

Опять же, основное внимание уделяется структуре данных, представленных в системе.

Пример дата-ориентированного проектирования

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

Структура RasterImage включает в себя:

  • битовый массив (представленный в виде массива байтов);

  • размеры битового массива (как ширину, так и высоту).

Операции с RasterImage включают базовые операции манипуляции изображением, такие как поворот и масштабирование; доступ к отдельным битам в битовом массиве и их изменение, а также объединение объектов RasterImage с помощью операции над изображениями. Мы предпочитаем не уточнять эту операцию; она может выполняться специализированным объектом для работы с изображениями (например, Smalltalk BitBlt [Gold83]) или различными другими стандартными битовыми операциями над изображениями.

Этот обзор структуры и операций RasterImage приводит нас к определению RasterImage со следующей типизированной структурой:

class Rasterlmage is
bitArray : ByteArray;
width : Integer;
height : Integer;

Мы определяем протокол для каждой желаемой операции. Неполный список протоколов мог бы включать: 

rotateBy(degree : Integer) : void;
scaleBy(x : Integer, y : Integer) : void;
bitAt(index : Point) : Integer;
bitAtPut(index : Point, bitValue : Integer) : void;

Мы также определяем протокол доступа и изменения каждой части структуры RasterImage:

bitArray : ByteArray;
bitArray(bits : ByteArray) : void;
width() : Integer;
width(aNumber : Integer) : void;
height() : Integer;
height(aNumber : Integer) : void;
end RasterImage;

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

class ShapedImage inherits RasterImage is
clipMask : ByteArray;

И мы определяем протокол доступа и изменения маски отсечения.

clipMask() : ByteArray;
clipMask(bits : ByteArray) : void;
end ShapedImage;

Основное преимущество дата-ориентированного проектирования

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

Основной недостаток дата-ориентированного проектирования

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

Ответственностно-ориентированное проектирование

Цель ответственностно-ориентированного проектирования — улучшение инкапсуляции. Этого оно достигает, рассматривая программу в категориях модели клиент/сервер.

Модель клиент/сервер

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

В объектно-ориентированном программировании и клиент, и сервер являются либо классами, либо экземплярами классов. Любой объект может выступать в роли клиента или сервера в любой данный момент времени.

Преимущество модели клиент/сервер в том, что внимание уделяется тому, что сервер делает для клиента, а не тому, как он это делает. Реализация сервера инкапсулирована — скрыта от клиента.

У класса может быть три различных типа клиентов: 

  • внешние клиенты,

  • клиенты-подклассы,

  • сам класс

Каждый из этих типов клиентов описан ниже.

Внешние клиенты

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

Клиенты-подклассы

Клиент-подкласс класса — это любой класс, наследующий этот класс. Суперкласс рассматривается как сервер; подкласс — как клиент. Подкласс не должен интересоваться реализацией какого-либо унаследованного поведения [Snyd86]. Набор сообщений, унаследованных подклассом, определяет контракт между клиентом и сервером.

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

Сам себе клиент

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

Определение ответственностно-ориентированного проектирования

Ответственностно-ориентированное проектирование основывается на модели клиент/сервер. Оно фокусируется на контракте, задавая вопросы:

  • За какие действия отвечает этот объект?

  • Какую информацию предоставляет этот объект?

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

Пример ответственностно-ориентированного проектирования

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

RasterImage отвечает за:

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

  • знание и поддержание всех отдельных битовых значений в его изображении;

  • поворот и масштабирование своего изображения.

Эти ответственности транслируются в следующий протокол:

class RasterImage is
boundingRectangle() : Rectangle;
boundingRectangle(bounds : Rectangle) : void;
bitAt(index : Point) : Integer;
bitAtPut(index : Point, bitvalue : Integer) : void;
scaleBy(x : Integer, y : Integer) : void;
rotateBy(degrees : Integer) : void;
end RasterImage;

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

class ShapedImage inherits RasterImage is
isVisibleAt(index : Point) : Boolean;
makeVisibleAt(index : Point) : void;
makeInvisibleAt(index : Point) : void; 

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

shape() : Polygon;
shape(aPolygon : Polygon) : void;
end ShapedImage;

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

Взглянув на протокол ShapedImage, мы видим, что у объекта RasterImage вполне разумно спросить, виден ли он в данной точке (каждая точка внутри его ограничивающего прямоугольника видна, а все остальные — нет). Поэтому мы также переносим протокол isVisibleAt() в RasterImage.

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

Преимущества ответственностно-ориентированного проектирования

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

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

Проектирование классов без знания их структуры способствует тому, что иерархия наследования соответствует иерархии типов, поскольку известны лишь типы классов. Если иерархия наследования соответствует иерархии типов, тип каждого класса является подтипом типа каждого из его суперклассов [Card85]. У такого подхода есть два преимущества.

  • Он улучшает инкапсуляцию по отношению к клиентам-подклассам [Halb86], гарантируя, что все унаследованное поведение станет частью контракта подкласса.

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

Сравнение ответственностно-ориентированного и дата-ориентированного проектирования

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

Ответственностно-ориентированное проектирование может привести к раннему выявлению абстрактных классов. Например, основываясь на ответственностно-ориентированном проектировании иерархии объектов RasterImage, разумно рассмотреть определение абстрактного класса RasterImage в качестве суперкласса как для ShapedImage, так и для RectangularImage (нового класса, который принимает на себя старую роль RasterImage). Это позволяет нам абстрактно определить поведение, ожидаемое от любых растровых изображений, и делает добавление новых типов изображений более простым.

Структура объекта определяется в ходе реализации ответственностно-ориентированного проектирования. К тому времени соображения производительности могут привести к корректировке ответственностей объекта. Цель в ходе реализации — сохранить инкапсуляцию при пересмотре контрактов между клиентом и сервером.

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

Сравнение с законом Деметры

Закон Деметры также стремится сократить зависимости между классами и, таким образом, максимизировать инкапсуляцию [Lieb88, Lieb89]. Закон гласит, что сообщение может быть отправлено только: 

  • аргументам сообщения, 

  • переменным экземпляра, 

  • новым объектам, возвращаемым при отправке сообщения,

  • глобальным переменным.

Сильная форма закона запрещает отправку сообщений унаследованным переменным экземпляра. Слабая форма закона позволяет ссылаться на унаследованные переменные экземпляра.

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

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

Хотя закон Деметры действительно улучшает инкапсуляцию, он также может усложнить проектирование или реализацию набора услуг, предоставляемых классом. Чтобы избежать отправки сообщения возвращаемому значению (которое считается частью структуры получателя), Либерхер, Холланд и Риль предлагают добавлять сообщения ко всем промежуточным объектам-серверам [Lieb88, Lieb89]. Эти методы существуют для ретрансляции сообщений компоненту структуры объекта. Если довести эту технику инкапсуляции до крайности, то все возможные сообщения, воспринимаемые публичными переменными экземпляра объекта, должны будут иметь соответствующие методы-ретрансляторы. Добавление этих сообщений объекту почти наверняка нарушит иерархию типов. В результате страдают сопровождаемость и расширяемость.

Слабая форма закона Деметры обеспечивает инкапсуляцию для внешних клиентов объекта. Сильная форма закона обеспечивает инкапсуляцию для клиентов-подклассов. Соответствующей формулировки закона для реализации самоинкапсуляции не существует. В ответственностно-ориентированном проектировании мы обеспечиваем инкапсуляцию для себя, подклассов и внешних клиентов одним и тем же приемом: ограничивая доступ к переменным [Wirf88b, Scha86].

Поддержка на уровне языка

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

Ограничение доступа к переменным

Прямой доступ к состоянию (переменным) объекта нарушает инкапсуляцию класса в отношении как минимум одного из трех типов контрактов, описанных в разделе «Модель клиент/сервер» [Wirf88b]. Какой именно тип контракта нарушается, зависит от типа клиента, осуществляющего доступ к состоянию.

Smalltalk-80 [Gold83] и Objective-C [Cox86], наряду с другими языками, препятствуют доступу внешних клиентов к структуре сервера. Такие языки, как Trellis/Owl [Scha86], Self [Unge87] и Modular Smalltalk [Wirf88], обеспечивают инкапсуляцию для всех типов клиентов, полностью исключая переменные экземпляра.

Ограничение доступа к поведению

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

Modular Smalltalk поддерживает лишь два из этих уровней доступа: видимость для всех клиентов (public) и видимость для себя (private). C++ [Stro87], напротив, поддерживает все три уровня защиты членов класса (переменных или функций): видимость для всех клиентов (public), видимость для подклассов и себя (protected) и видимость для себя (private).

Eiffel [Meye88] допускает куда более детальную спецификацию доступа. Каждому компоненту (переменной или функции) можно задать список классов, которые могут иметь к нему доступ. В этот список входят клиенты, в контракте которых присутствует этот компонент.

Заключение

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

Дата-ориентированный подход к объектно-ориентированному проектированию фокусируется на структуре данных в системе. Это приводит к включению информации о структуре в определения классов и тем самым — к нарушению инкапсуляции.

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

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

Источники

Cardelli L., Wegner P. On Understanding Types, Data Abstraction, and Polymorphism // ACM Computing Surveys, vol. 17, № 4, December 1985.

Cox B. Object-Oriented Programming: An Evolutionary Approach. Reading: Addison-Wesley, 1986.

Golberg A., Robson D. Smalltalk-80: The Language and Its Implementation. Reading: Addison-Wesley, 1983.

Halbert D.C., O’Brien P.D. Using Types and Inheritance in Object-Oriented Languages // Technical Report DEC-TR-437, Digital Equipment Corporation, April 1986.

Lieberherr K.J., Holland I., Riel A.J. Object-Oriented Programming: An Objective Sense of Style // ACM SIGPLAN Notices, vol. 23, № 11, November 1988.

Lieberherr K.J., Holland I. Formulations and Benefits of the Law of Demeter // ACM SIGPLAN Notices, vol. 24, № 3, March 1989.

Meyer B. Object-Oriented Software Construction. Englewood Cliffs: Prentice Hall, 1988 (Мейер Б. Объектно-ориентированное конструирование программных систем. М.: Русская редакция, 2005).

Schaffert C., Cooper T., Bullis B., Kilian M., Wilpolt C. An Introduction to Trellis/Owl // ACM SIGPLAN Notices, vol. 21, № 11, November 1986.

Snyder A. Encapsulation and Inheritance in Object-Oriented Programming Languages // ACM SIGPLAN Notices, vol. 21, № 11, November, 1986.

Stroustrup B. The C++ Programming Language. Reading: Addison-Wesley, 1987 (Страуструп Б. Язык программирования С++. М.: Бином, 2006).

Unger D., Smith R.B. Self: The Power of Simplicity // ACM SIGPLAN Notices, vol. 22, № 12, December 1987.

Wirfs-Brock A., Wilkerson B. An Overview of Modular Smalltalk // ACM SIGPLAN Notices, vol. 23, № 11, November 1988.

ссылка на оригинал статьи https://habr.com/ru/articles/1046520/