Аннотация
Объектно-ориентированные языки программирования поддерживают инкапсуляцию, тем самым расширяя возможности повторного использования, доработки, тестирования, сопровождения и расширения программного обеспечения. Чтобы получить все преимущества этой поддержки, необходимо обеспечить максимальную инкапсуляцию уже на этапе проектирования.
Мы утверждаем, что практики проектирования, использующие подход через данные (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;
Основное преимущество проектирования через данные
Основная польза от подхода через данные заключается в том, что этот процесс привычен для программистов, имеющих опыт работы с традиционными процедурными языками. Таким программистам относительно легко адаптировать свой предыдущий опыт к проектированию объектно-ориентированных систем.
Основной недостаток проектирования через данные
И хотя цель проектирования через данные — инкапсуляция данных и алгоритмов, оно изначально нарушает эту самую инкапсуляцию, делая структуру объекта частью его определения. А это, в свою очередь, ведет к определению операций, отражающих эту структуру (поскольку они проектировались с оглядкой на нее). Попытки прозрачно изменить структуру объекта обречены на провал, потому что от этой структуры уже зависят другие классы. Это противоположность инкапсуляции.
Проектирование через ответственность
Цель проектирования через ответственность — улучшение инкапсуляции. Эта цель достигается путем рассмотрения программы в категориях модели клиент/сервер.
Модель клиент/сервер
Модель клиент/сервер описывает взаимодействие между двумя сущностями: клиентом и сервером. Клиент отправляет серверу запросы на выполнение услуг. Сервер предоставляет набор услуг по запросу. Способы, которыми клиент может взаимодействовать с сервером, описываются контрактом: перечнем запросов, которые клиент может отправить серверу. Оба должны соблюдать контракт: клиент — выполняя только те запросы, которые определяет контракт, а сервер — отвечая на эти запросы.
В объектно-ориентированном программировании и клиент, и сервер являются либо классами, либо экземплярами классов. Любой объект может выступать в роли клиента или сервера в любой данный момент времени.
Преимущество модели клиент/сервер в том, что внимание уделяется тому, что сервер делает для клиента, а не тому, как он это делает. Реализация сервера инкапсулирована — скрыта от клиента.
У класса может быть три различных типа клиентов:
внешние клиенты,
клиенты-подклассы,
сам класс
Каждый из этих типов клиентов описан ниже.
Внешние клиенты
Внешний клиент класса — это объект, который отправляет сообщения экземпляру класса или самому классу. Получатель рассматривается как сервер, а отправитель — как клиент. То, как отвечают на сообщение, не должно быть важно для отправителя. Набор сообщений, на которые отвечает объект, включая типы аргументов и возвращаемый тип, определяет контракт между клиентом и сервером.
Клиенты-подклассы
Клиент-подкласс класса — это любой класс, наследующий этот класс. Суперкласс рассматривается как сервер; подкласс — как клиент. Подкласс не должен интересоваться реализацией унаследованного поведения [9]. Набор сообщений, унаследованных подклассом, определяет контракт между клиентом и сервером.
В большинстве объектно-ориентированных языков подклассы наследуют не только поведение, но и структуру, определенную их суперклассами. Это нехорошо, поскольку подталкивает программистов нарушать инкапсуляцию для увеличения количества повторно используемого кода.
Сам себе клиент
В целях максимизации инкапсуляции классы должны также рассматриваться как клиенты самих себя. Объем кода, непосредственно зависящего от структуры класса, должен быть минимизирован. Иными словами, структура класса должна быть инкапсулирована в минимальном количестве методов. Это минимизирует зависимость объекта от его собственной структуры, позволяя изменять эту структуру максимально прозрачно.
Определение проектирования через ответственность
Проектирование через ответственность основывается на модели клиент/сервер. Оно фокусируется на контракте, задавая вопросы:
За какие действия отвечает этот объект?
Какую информацию предоставляет этот объект?
Важно заметить, что информация, которую предоставляет объект, может быть, а может и не быть частью структуры объекта. То есть объект может вычислять информацию, а может делегировать запрос на информацию другому объекту.
Пример проектирования через ответственность
Теперь продемонстрируем результаты проектирования через ответственность, используя те же классы, что и раньше: 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, и она нас пока не беспокоит. Мы предпочитаем сначала сосредоточиться на полной спецификации желаемого поведения и соответствующих наборов ответственностей для каждого объекта. О структуре мы будем думать на этапе реализации.
Преимущества проектирования через ответственность
Инкапсуляция нарушается, когда структурные детали объекта становятся частью его интерфейса. Это может произойти только в том случае, если проектировщик использует знание этих структурных деталей. Проектирование через ответственность максимизирует инкапсуляцию именно тогда, когда проектировщик сознательно игнорирует эту информацию.
Полиморфизм усиливает инкапсуляцию, поскольку клиенту объекта не нужно знать ни того, как объект реализует запрашиваемую услугу, ни того, какой класс (тип) отвечает на запрос. Подход через ответственность может помочь проектировщику выявить стандартные протоколы (имена сообщений), побуждая его сосредоточиться на ответственностях независимо от реализации. Это способствует полиморфизму.
Проектирование классов без знания их структуры способствует тому, чтобы иерархия наследования соответствовала иерархии типов, поскольку известны лишь типы классов. Если иерархия наследования соответствует иерархии типов, тип каждого класса является подтипом типа каждого из его суперклассов [1]. У такого подхода есть два преимущества.
Он улучшает инкапсуляцию по отношению к клиентам-подклассам [4], гарантируя, что все унаследованное поведение станет частью контракта подкласса.
Он упрощает выявление абстрактных классов. Одна из трудностей при выявлении абстрактных классов заключается в определении того, какие части протокола существующих классов являются частью типа этих классов, а какие — деталями реализации. Поскольку протокол класса включает только те сообщения, которые формируют тип класса, эта проблема устраняется.
Сравнение проектирования через ответственность и проектирования через данные
Проектирование через ответственность определяет поведение объекта до того, как будут определены его структура и другие аспекты реализации. Мы выяснили, что это минимизирует объем доработок, необходимых при серьезных изменениях в проекте.
Проектирование через ответственность может привести к раннему выявлению абстрактных классов. Например, основываясь на проектировании иерархии объектов RasterImage через ответственность, разумно рассмотреть определение абстрактного класса RasterImage в качестве суперкласса как для ShapedImage, так и для RectangularImage (нового класса, который принимает на себя старую роль RasterImage). Это позволяет нам абстрактно определить поведение, ожидаемое от любых растровых изображений, и делает добавление новых типов изображений более простым.
Структура объекта определяется в ходе реализации проектных решений, основанных на ответственности. На этом этапе соображения производительности могут привести к корректировке ответственностей объекта. Цель в ходе реализации — сохранить инкапсуляцию при пересмотре контрактов между клиентом и сервером
Проектирование через данные, напротив, быстро приводит к предписыванию структуры объекта. Построение абстрактных классов подразумевает нахождение абстракции и ее вынесение из набора конкретных классов. Объекты-клиенты объекта, спроектированного через данные, могут становиться (и часто становятся) зависимыми от конкретной структуры объекта, что затрудняет задачу выработки абстракции при сохранении контракта между клиентами и серверами.
Сравнение с законом Деметры
Закон Деметры также стремится сократить зависимости между классами и, таким образом, максимизировать инкапсуляцию [5, 6]. Закон гласит, что сообщение может быть отправлено только:
аргументам сообщения,
переменным экземпляра,
новым объектам, возвращаемым при отправке сообщения,
глобальным переменным.
Сильная форма закона запрещает отправку сообщений унаследованным переменным экземпляра. Слабая форма закона позволяет ссылаться на унаследованные переменные экземпляра.
Закон Деметры обеспечивает инкапсуляцию, ограничивая доступ к объектам. На наш взгляд, он чрезмерно ограничивает возможные связи между объектами. Закон Деметры является следствием восприятия объектно-ориентированного программирования через данные. Все объекты, возвращаемые в результате отправки сообщения, рассматриваются как потенциально нарушающие инкапсуляцию, поскольку эти возвращаемые объекты могут отражать внутреннюю структуру получателя.
Такой взгляд, рассматривающий все возвращаемые объекты как «троянских коней», напрямую противоречит нашему пониманию отношений клиент/сервер. При подходе через ответственность возвращаемые значения являются частью контракта между клиентом и сервером. Между структурой объекта и объектом, возвращаемым сообщением, не обязательно должна существовать какая-либо связь.
Хотя закон Деметры действительно улучшает инкапсуляцию, он также может усложнить проектирование или реализацию набора услуг, предоставляемых классом. Чтобы избежать отправки сообщения возвращаемому значению (которое считается частью структуры получателя), Либерхер, Холланд и Риэль предлагают добавлять сообщения ко всем промежуточным объектам-серверам [5, 6]. Эти методы существуют для ретрансляции сообщений компоненту структуры объекта. Если довести эту технику инкапсуляции до крайности, то все возможные сообщения, воспринимаемые публичными переменными экземпляра объекта, должны будут иметь соответствующие методы-ретрансляторы. Добавление этих сообщений объекту почти наверняка нарушит иерархию типов. В результате страдают сопровождаемость и расширяемость.
Слабая форма закона Деметры обеспечивает инкапсуляцию для внешних клиентов объекта. Сильная форма закона обеспечивает инкапсуляцию для клиентов-подклассов. Соответствующей формулировки закона для реализации самоинкапсуляции не существует. В проектировании через ответственность мы обеспечиваем инкапсуляцию для самого класса, подклассов и внешних клиентов одним и тем же приемом: ограничивая доступ к переменным [13, 8].
Поддержка на уровне языка
Существует множество языковых механизмов, которые помогают сохранить высокий уровень инкапсуляции, достигаемый на этапе проектирования. Два из них обсуждаются ниже: ограничение доступа к переменным и ограничение доступа к поведению. Если язык не предоставляет прямой поддержки хотя бы одного из этих двух механизмов, для достижения того же результата следует придерживаться стилистических соглашений.
Ограничение доступа к переменным
Прямой доступ к состоянию (переменным) объекта нарушает инкапсуляцию класса в отношении как минимум одного из трех типов контрактов, описанных в разделе «Модель клиент/сервер» [13]. Какой именно тип контракта нарушается, зависит от типа клиента, осуществляющего доступ к состоянию.
Smalltalk-80 [3] и Objective-C [2], наряду с другими языками, препятствуют доступу внешних клиентов к структуре сервера. Такие языки, как Trellis/Owl [8], Self [11] и Modular Smalltalk [12], обеспечивают инкапсуляцию для всех типов клиентов, полностью исключая переменные экземпляра.
Ограничение доступа к поведению
Кроме ограничения доступа к состоянию, язык также должен позволять программисту ограничивать доступ к поведению класса. Разумно предположить, что клиенты-подклассы должны иметь те же права доступа, что и внешние клиенты. Права доступа, предоставляемые самому классу, должны аналогичным образом включать все права доступа, которые есть у клиентов-подклассов.
Modular Smalltalk поддерживает лишь два из этих уровней доступа: видимость для всех клиентов (public) и видимость для себя (private). C++ [10], напротив, поддерживает все три уровня защиты членов класса (переменных или функций): видимость для всех клиентов (public), видимость для подклассов и себя (protected) и видимость для себя (private).
Eiffel [7] допускает куда более детальную спецификацию доступа. Каждому компоненту (переменной или функции) можно задать список классов, которые могут иметь к нему доступ. В этот список входят клиенты, в контракте которых присутствует этот компонент.
Заключение
Инкапсуляция — ключ к повышению значения таких метрик программного обеспечения, как возможность повторного использования, доработки, тестирования, сопровождения и расширения. Объектно-ориентированные языки предоставляют ряд механизмов для улучшения инкапсуляции, однако максимального результата можно достичь именно на этапе проектирования.
Подход к объектно-ориентированному проектированию через данные фокусируется на структуре данных в системе. Это приводит к включению информации о структуре в определения классов и тем самым — к нарушению инкапсуляции.
Подход через ответственность делает акцент на инкапсуляции как структуры, так и поведения объектов. Сосредоточившись на обязанностях класса, определенных контрактом, проектировщик может отложить вопросы реализации до этапа непосредственного воплощения.
Хотя проектирование через ответственность — не единственная техника, решающая эту проблему, большинство других техник пытаются обеспечить инкапсуляцию на этапе реализации. В жизненном цикле программного обеспечения это уже слишком поздно, чтобы добиться максимальных результатов.
Источники
[1] Cardelli L., Wegner P. On Understanding Types, Data Abstraction, and Polymorphism // ACM Computing Surveys, vol. 17, № 4, December 1985.
[2] Cox B. Object-Oriented Programming: An Evolutionary Approach. Reading: Addison-Wesley, 1986.
[3] Golberg A., Robson D. Smalltalk-80: The Language and Its Implementation. Reading: Addison-Wesley, 1983.
[4] 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.
[5] Lieberherr K.J., Holland I., Riel A.J. Object-Oriented Programming: An Objective Sense of Style // ACM SIGPLAN Notices, vol. 23, № 11, November 1988.
[6] Lieberherr K.J., Holland I. Formulations and Benefits of the Law of Demeter // ACM SIGPLAN Notices, vol. 24, № 3, March 1989.
[7] Мейер Б. Объектно-ориентированное конструирование программных систем. М.: Русская редакция, 2005.
[8] Schaffert C., Cooper T., Bullis B., Kilian M., Wilpolt C. An Introduction to Trellis/Owl // ACM SIGPLAN Notices, vol. 21, № 11, November 1986.
[9] Снайдер А. Инкапсуляция и наследование в объектно-ориентированных языках программирования // https://habr.com/ru/articles/1047584/.
[10] Страуструп Б. Язык программирования С++. М.: Бином, 2006.
[11] Unger D., Smith R.B. Self: The Power of Simplicity // ACM SIGPLAN Notices, vol. 22, № 12, December 1987.
[12] Wirfs-Brock A., Wilkerson B. An Overview of Modular Smalltalk // ACM SIGPLAN Notices, vol. 23, № 11, November 1988.
[13] Wirfs-Brock A., Wilkerson A., Wilkerson B. Variables Limit Reusability // Journal of Object-Oriented Programming, vol. 2, № 1, May/June, 1989.

