Глава     17

Видимость и зависимость


Разделы

Содержание

В главе═1 мы выяснили, что взаимозависимость программных компонент является основным препятствием на пути разработки многократно используемого кода. Этот факт давно признан сообществом разработчиков. Имеется литература, посвященная характеристике взаимозависимости компонент, где приведены правила, как избегать ╚вредных╩ связей (см., к примеру, [Gillet═1982] и [Fairley 1885]). В этой главе мы исследуем некоторые из этих соображений в контексте объектно-ориентированного программирования.

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

Видимость тесно связана с взаимозависимостью: управляя видимостью имени идентификатора, мы можем более четко охарактеризовать использование идентификатора. В языке Smalltalk, к примеру, видимость переменных экземпляра ограничена методами═≈ к таким переменным невозможен непосредственный доступ вне метода. Это не означает, что они не могут изменяться или считываться извне класса. Все такие действия, однако, должны проводиться при посредничестве метода. С другой стороны, в языке Object Pascal версии Apple переменные экземпляра видны всюду, где известно имя соответствующего класса. То есть этот язык не обеспечивает механизмов, гарантирующих, что переменные экземпляра модифицируются только методами. Вместо этого нам приходится полагаться на надлежащее поведение пользователей.

Понятие зависимости соотносит различные части приложения. Если программная система (класс, модуль и т.═д.) не может осмысленно существовать в отрыве от другой системы, говорят, что первая система зависит от второй. Например, дочерний класс почти всегда зависит от своего родителя. Зависимости могут быть и гораздо более тонкими, как мы увидим в следующем разделе.

17.1. Зацепление и связность

Разделы

Понятия зацепления и связности были введены Стивенсом, Константайном и Майерсом [Stevens═1981] для оценки эффективного использования модулей. Мы будем обсуждать их, имея в виду языки, поддерживающие модули, и только затем перейдем к объектно-ориентированным языкам.

Зацепление описывает отношения между модулями, а связность═≈ внутри них. Уменьшение взаимозависимости между модулями (или классами) достигается, следовательно, за счет уменьшения зацепления. С другой стороны, хорошо разработанные модули должны служить некоторой цели, то есть все элементы модуля должны быть связаны общей задачей. Это означает, что хорошо разработанный модуль должен быть внутренне связным.

17.1.1. Разновидности зацепления

Зацепление и связность

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

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

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

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

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

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

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

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

17.1.2. Разновидности связности

Зацепление и связность

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

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

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

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

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

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

Функциональная связность желательна. При ее наличии все элементы модуля связаны выполнением единой задачи.

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

Часто можно оценить степень связности модуля, если кратко сформулировать предложение, описывающее его предназначение (вспомните CRC-карточки из главы═2). Следующий набор тестов был предложен Константайном:

  1. Если предложение, которое описывает предназначение модуля, составное, то есть содержит запятую и более одного глагола, то модуль (скорее всего) выполняет более одной функции. Вероятно, он обладает последовательной или коммуникационной связностью.
  2. Если предложение содержит слова, имеющие отношение ко времени (такие, как ╚первый╩, ╚следующий╩, ╚затем╩, ╚после╩, ╚когда╩, ╚начать╩), то модуль, вероятно, обладает последовательной или временной связностью. Например: ╚Подождать, пока экземпляр не получит сигнал, что пользователь вставил электронную карту, затем запросить индивидуальный идентификационный номер╩.
  3. Если предикат предложения не содержит единого, конкретного объекта, следующего за глаголом, то модуль, вероятно, обладает логической связностью. Например, утверждение ╚редактировать все данные╩ обладает логической связностью. Высказывание ╚редактировать исходные данные╩ может обладать функциональной связностью.
  4. Если предложение содержит слова вроде ╚инициализировать╩ или ╚обновить╩, то модуль скорее всего обладает временной связностью.

17.1.3. Зацепление и связность в ООП

Зацепление и связность

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

17.1.4. Закон Деметера

Зацепление и связность

Советы по написанию программ бывают абстрактными (╚модули должны обладать внутренней связностью и минимизировать зацепление╩) и конкретными (╚процедуры не должны содержать более═60 строк кода╩). Конкретные идеи легче понимать и применять, но они часто убаюкивают программистов (и администраторов проекта) ложным чувством безопасности и отвлекают внимание от настоящей проблемы. В качестве средства снижения сложности программы правило, ограничивающее процедуры размером═60 строк, является в лучшем случае условным. Короткая процедура со сложной логической структурой бывает гораздо более трудной для понимания и правильного кодирования, чем длинная последовательность прямолинейных команд присваивания.

Аналогично, фанатическая попытка некоторых людей несколько лет назад выбросить оператор goto часто уводила в неправильном направлении. Оператор goto сам по себе просто симптом болезни, а не болезнь. Утверждение состояло не в том, что команда goto является плохой от природы и что программы, которые ее избегают, являются однозначно более хорошими, но в том, что при использовании goto труднее понять смысл программы. Важна понятность программ, а не оператор goto. Тем не менее мы не можем игнорировать тот факт, что это простое правило является полезным, его легко применять и оно эффективно в большинстве случаев в смысле достижения желаемого результата. Спросим себя: могут ли быть созданы такие руководящие правила для объектно-ориентированных программ?

Одно из таких правил было предложено Карлом Либерхером в результате его работы над средством объектно-ориентированного программирования под названием Demeter. Правило получило названия закон Деметера [Lieberherr 1989a, Lieberherr═1989b]. Имеются две формы этого закона: слабая и сильная. Обе стремятся уменьшить зацепление объектов за счет ограничения связей между ними.

Закон Деметера. В методе M класса C должны использоваться исключительно методы:

Если перефразировать этот закон в терминах объектов, а не методов, то мы приходим к следующему утверждению.

Закон Деметера (слабая форма). Только следующие объекты должны выступать в роли источника данных и приемника сообщений метода:

  1. Аргументы выполняемого метода (включая объект self).
  2. Экземпляры получателя метода.
  3. Глобальные переменные (и доступные во всей программе и те, что видны в одном файле).
  4. Временные переменные, создаваемые внутри метода.

Этот закон в своей сильной форме разрешает доступ из метода только к экземплярам класса, в котором определен метод. Доступ к экземплярам суперкласса должен осуществляться исключительно посредством функций доступа.

Закон Деметера (сильная форма). Только следующие объекты должны выступать в роли источника данных и приемника сообщений метода:

  1. Аргументы выполняемого метода (включая объект self).
  2. Экземпляры класса, содержащего выполняемый метод.
  3. Глобальные переменные.
  4. Временные переменные, создаваемые внутри метода.

Полезно рассмотреть, какие правила доступа вытекают из закона Деметера, и соотнести его с концепциями зацепления и связности, описанными выше. Основной вид доступа, который запрещается правилами Деметера,═≈ это прямая обработка (манипулирование) полей экземпляра другого класса. В противном случае один объект зависит от внутреннего представления другого объекта (что является формой зацепления внутренних данных). Соблюдение этого правила приводит к тому, что классы могут изучаться и быть понятными независимо друг от друга (поскольку они взаимодействуют между собой простым, четко определенным образом). Вирфс-Брок и Вилкерсон еще более ужесточают закон Деметера, считая, что ссылки из метода даже на переменные экземпляра того же класса должны выполняться через функции доступа [Wirfs-Brock═1989a]. Их аргументация состоит в том, что непосредственный доступ к переменным серьезно ограничивает возможность программиста дорабатывать существующие классы.

17.1.5. Видимость: на уровне классов и на уровне объектов

Зацепление и связность

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

Языки, которые управляют видимостью на уровне классов (например, C++), рассматривают все экземпляры класса одним способом. Как мы скоро узнаем, C++ допускает контроль видимости идентификаторов, но даже в наиболее ограничительном случае (поля данных описаны с ключевым словом private) экземпляр класса всегда имеет возможность доступа к полям данных других экземпляров того же класса. То есть объектам всегда разрешен доступ ко внутреннему состоянию родственных объектов.

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

17.1.6. Активные значения

Зацепление и связность

Активное значение [Stefik═1986]═≈ это переменная, с которой мы хотим выполнять некоторые действия всякий раз, когда изменяется ее значение. Система с активными значениями иллюстрирует, почему зацепление через параметры предпочтительнее, чем другие виды зацепления, особенно для объектно-ориентированных языков. Предположим, что модель ядерного реактора включает в себя класс Reactor, который содержит различную информацию о состоянии реактора. Среди наблюдаемых параметров значится температура теплоотводящей среды (воды, циркулирующей вокруг блока). Далее предположим, что эта величина модифицируется с применением классического объектно-ориентированного подхода: значение устанавливается через метод setHeat, а считывается через функцию getHeat. Класс выглядит следующим образом:

@interface Reactor : Object
{
  ...
  double heat; ...
}
- (void)   setHeat: (double) newValue;
- (double) getHeat;

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

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

@implementation GraphicalReactor : Reactor
- (void) setHeat: (double) newValue
 {
   /* код, необходимый для обновления датчика */
   [ super setHeat: newValue ];
 }
@end

Языки Smalltalk и Objective-C поддерживают более общую концепцию, называемую зависимость. Мы обсуждаем ее в разделе═17.4.

17.2. Клиенты-подклассы и клиенты-пользователи

Разделы

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

Алан Снайдер [Snyder═1986] и другие исследователи отмечали, что наследование в объектно-ориентированных языках программирования означает, что классы имеют еще и третью составляющую═≈ а именно свойства, доступные для подклассов, но не нужные другим пользователям. Разработчику подкласса данного класса потребуется, вероятно, знать больше о внутреннем устройстве исходного класса, чем пользователю экземпляров класса. Однако и разработчик подкласса не нуждается во всей информации об исходном классе.

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

В наборе классов, созданных нами в главе═8 как часть карточного пасьянса, класс Card описывает переменные r и s (содержащие ранг и масть игральной карты) как закрытые. Только методы класса Card могут иметь доступ или модифицировать эти переменные. С другой стороны, данные класса CardPile разбиты на три категории: закрытые private, защищенные protected и открытые public. Закрытая переменная firstCard доступна только изнутри класса CardPile, в то время как защищенные поля данных x и y доступны либо через класс, либо через его подклассы. Единственный общедоступный универсальный интерфейс═≈ через методы. При устранении открытых переменных экземпляра язык программирования гарантирует, что между классом и другими компонентами программы не возникнет зацепления по данным. (Однако язык лишь предоставляет соответствующий механизм. Правильное его использование остается обязанностью программиста═≈ например, путем описания полей данных как private или protected.)

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

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

17.3. Управление доступом и видимостью

Разделы

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

17.3.1. Видимость в Smalltalk

Управление доступом и видимостью

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

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

17.3.2. Видимость в Object Pascal

Управление доступом и видимостью

Язык Object Pascal версии Apple обеспечивает небогатые средства управления видимостью полей объекта. Все поля═≈ как данные, так и методы═≈ доступны и для клиентов-пользователей, и для клиентов-подклассов. Только в силу традиции или соглашения поля данных считаются открытыми для разработчиков подклассов, а методы═≈ для клиентов-пользователей. Даже если руководящие указания по стилю программирования (подобные законам Деметера) и не могут строго контролироваться системой, они все-таки остаются в силе и должны уважаться программистом. Полезно также, если программист указывает в комментариях на те методы класса, которые следует переопределить в подклассах.

Версия языка фирмы Borland является немного более мощной в этом отношении. Delphi поддерживает ключевые слова public, protected и private в смысле, очень близком к их значению в языке C++. Однако внутри раздела implementation библиотек unit все поля рассматриваются как открытые. Это позволяет экземплярам иметь доступ к закрытым полям данных своих родственников.

17.3.3. Видимость в C++

Управление доступом и видимостью

Из всех рассматриваемых нами языков C++ обеспечивает наиболее богатый набор средств контроля доступа к информации. Как мы отмечали в предыдущих главах, это обеспечивается тремя ключевыми словами: public, protected и private.

Когда указанные ключевые слова используются при описании полей данных класса, их эффект описывается почти непосредственно в терминах раздела═17.2. Данные, которые следуют за спецификатором доступа public:, доступны в равной мере и клиентам-подклассам, и клиентам-пользователям. Поля, определенные со спецификатором protected:, доступны только внутри класса и его подклассов и поэтому предназначены для клиентов-подклассов, но не для клиентов-пользователей. Наконец, спецификатор доступа private: предшествует полям данных, которые доступны исключительно экземплярам самого класса: они закрыты и для подклассов, и для пользователей. При отсутствии какого-либо явного спецификатора поля данных рассматриваются как private.

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

class Sneaky
{
    int safe;

public:

    // инициализировать поле safe значением═10
    Sneaky() { safe =═10; }
    int &sorry() { return safe; }
}

Хотя поле данных safe и описано как private, ссылка на него возвращается методом sorry. Следовательно, выражение вида

Sneaky x;
x.sorry() =═17;

изменит значение поля safe с═10 на═17 даже в том случае, когда вызов метода sorry осуществляется пользовательским кодом.

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

int i;  // глобальная переменная
class A
{
    int i;
};

class B : public A
{
  void f();
};

B::f()
{
  i++; // ошибка: A::i описано как private
}

Ошибка возникает, поскольку функция f пытается модифицировать переменную i, наследуемую из класса A, но недоступную (так как она описана как private:). Если бы спецификаторы доступа управляли видимостью, а не доступом, то переменная i класса A была бы не видна и обновлению подверглась бы глобальная переменная i.

Родственные экземпляры

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

В качестве примера рассмотрим описание класса, приведенное ниже. Поля данных rp и ip, которые означают вещественную и мнимую части комплексного числа, помечены как private:

class Complex
{
    double rp;
    double ip;

public:

    Complex (double a, double b)
     {
      rp = a; ip = b;
     }

    Complex operator + (Complex & x)
     {
      return Complex(rp+x.rp, ip+x.ip);
     }
};

Бинарная операция + перегружается с целью правильного сложения двух комплексных чисел. Несмотря на закрытую природу полей rp и ip, оператору-функции разрешен доступ к ним в аргументе x, поскольку аргумент и получатель относятся к одному классу.

Конструкторы и деструкторы, подобные функции Complex в приведенном примере, обычно описываются как public. Объявление конструктора как protected подразумевает, что только подклассы или дружественные классы (см. далее) могут создавать экземпляры этого класса, в то время как описание конструктора с ключевым словом private ограничивает создание новых экземпляров только ╚друзьями╩ и экземплярами самого класса.

Слабая форма законов Деметера частично выполняется при описании всех полей как защищенных (protected). Сильная форма реализуется при объявлении закрытых полей (private). Более подробный анализ приложения законов Деметера к языку C++ можно найти в работе [Sakkinen═1988b].

Хотя модификаторы доступа в C++ намного сильнее и гибче, чем в других рассматриваемых нами языках, эффективное использование этих свойств требует предусмотрительности и опыта. Как и в случае выбора между виртуальным и невиртуальным методами, уровень контроля, обеспечиваемый языками C++ или Delphi Pascal, приводит к тому, что легкость порождения подкласса зависит от того, что записал разработчик в исходном классе. Если класс является чрезмерно закрытым (защищенные поля данных объявлены закрытыми), то создание подкласса затруднено. Возникают серьезные проблемы, когда разработчик подкласса не может модифицировать исходную форму класса═≈ например, если исходный класс распространяется как часть библиотеки.

Закрытое наследование

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

Когда класс порождается закрытым образом, экземпляры подкласса не должны присваиваться идентификаторам надкласса (такое возможно при открытом наследовании). Простой способ запомнить указанное ограничение ≈ воспользоваться условием ╚быть экземпляром╩. Наследование через модификатор public означает, что выполнено условие ╚быть экземпляром╩, и тем самым экземпляры подкласса могут использоваться везде, где встречаются экземпляры надкласса. Собака Dog ╚является экземпляром╩ класса млекопитающих Mammal, и, следовательно, Dog может использоваться во всех ситуациях, где встречается Mammal. Закрытое наследование не подразумевает выполнения условия ╚быть экземпляром╩, поскольку экземпляры порожденного класса не могут во всех случаях использоваться вместо экземпляров родителя. Например, бессмысленно применять класс таблиц символов SymbolTable (наследующий от более общего класса словарей Dictionary через модификатор private) там, где требуется использование класса Dictionary. Если переменная описана с типом данных Dictionary, ей нельзя присваивать значение типа SymbolTable (это было бы разрешено, если бы наследование было открытым).

Дружественные функции

Другой аспект видимости в языке C++═≈ это дружественные функции. Они представляют собой обычные функции (не методы), которые описаны с модификатором friend в определении класса. Дружественным функциям разрешается читать и записывать в поля данных объекта, описанные и как private, и как protected.

Рассмотрим описание класса, расширяющее приведенное выше определение комплексных чисел:

class Complex
{
    double rp;
    double ip;

public:

    Complex(double, double);
    friend double abs(Complex&);
};

Complex::Complex(double a, double b)
{
  rp = a; ip = b;
}

double abs(Complex& x)
{
  return sqrt(x.rp*x.rp + x.ip*x.ip);
}

Поля данных rp и ip в структуре данных, представляющей комплексные числа, описаны с модификатором private, и тем самым недоступны вне методов класса. Функция abs, которая перегружает функцию с тем же именем, определенную для вещественных значений с двойной точностью, не является методом (это═≈ обычная функция). Однако поскольку она описана как дружественная с модификатором friend в классе комплексных чисел, ей разрешен доступ ко всем полям данных класса, в том числе и к закрытым.

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

Дружественные функции являются мощным средством, но они также легко могут стать источником проблем. В частности, они вводят в точности ту разновидность зацепления данных, которая идентифицировалась в начале этой главы как вредная для разработки многократно используемого программного обеспечения. Везде, где только возможно, более объектно-ориентированные методы инкапсуляции (например, методы) должны иметь предпочтение перед дружественными функциями. Тем не менее есть случаи, когда нет других средств═≈ например, функции требуется доступ ко внутренней структуре двух (или более) классов. В таких случаях дружественные функции являются полезной абстракцией [Koenig═1989c].

Пространства имен

Другое недавнее изменение в языке C++═≈ введение пространства имен (namespace). namespace помогает предотвратить размножение глобальных имен. Ключевое слово static ограничивает область видимости одним файлом. Поэтому когда прежде требовалось сделать некоторое имя совместно используемым в двух файлах, то единственный выход состоял в том, чтобы сделать его глобальным. Подобные имена могут теперь вкладываться внутрь описаний namespace:

namespace myLibrary
{
  int x;

  class A
  {
    ...
  };

  class B : public A
  {
    ...
  };
  ...
}

Переменные, описанные внутри namespace, не являются глобальными. Если программист хочет подключить какое-нибудь конкретное пространство имен, он его явно указывает. В результате, все имена верхнего уровня, определенные внутри указанного пространства имен, становятся видимыми:

using namespace myLibrary;

Индивидуальные элементы также могут экспортироваться из конкретного пространства имен с помощью явного указания либо пространства целиком, либо отдельного имени:

myLibrary::A anA;   // явно подключаем все пространство имен
using myLibrary::B; // импортируем только класс B
B aNewB;            // теперь B═≈ имя типа данных

Постоянные члены

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

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

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

class Complex
{
public:

    const double rp;
    const double ip;
    Complex(double, double);
};

Complex::Complex(double a, double b) : rp(a), ip(b)
{
  /* пустая команда */
}

В этом случае поля данных rp и ip описаны через модификатор const, так что не представляет опасности сделать их полями public, поскольку они все равно не могут быть модифицированы. Чтобы присвоить им начальное значение, конструктор, по-видимому, вызывает rp и ip, как если бы они были надклассами. Это═≈ единственный способ присваивания значений постоянным полям. Когда начинает выполняться тело конструктора, значение постоянных полей данных уже не может быть изменено.

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

Взаимодействие между перегрузкой и переопределением

Другой приводящий в смущение аспект правил видимости в C++═≈ это связь понятий перегрузки и переопределения. Имена функций, включая методы классов, могут перегружаться двумя или более определениями до тех пор, пока списки их аргументов достаточно различны с точки зрения компилятора. Это показывает следующее описание класса, которое перегружает функцию test за счет использования целочисленного аргумента в одном случае и вещественного═≈ в другом:

class A
{
public:

    void test(int a)
     {
      cout << "This is the integer version\n";
     }

    void test(double b)
     {
      cout << "This is the floating point version\n";
     }
};

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

class A
{
public:

    void test(double b)
     {
      cout << "This is the floating point version\n";
     }
};

class B : public A
{
public:

    void test(int a)
     {
      cout << "This is the integer version\n";
     }
};

Попытка послать сообщение test с вещественным аргументом экземпляру класса B приведет к предупреждению компилятора, поскольку в области имен, в которой компилятор нашел метод с именем test (а именно, внутри класса B), нет определения функции с вещественным аргументом. Это происходит, несмотря на то, что нужная функция может быть унаследована от класса A. Результат будет тем же самым независимо от того, описана функция test как virtual или нет. Чтобы справиться со всем этим, программисту нужно определить обе версии в классе B, одна из которых будет просто вызывать соответствующую функцию родителя:

class B : public A
{
public:

    void test(double b)
     {
      A::test(b);
     }

    void test(int a)
     {
      cout << "This is the integer version\n";
     }
};

17.3.4. Видимость в Java

Управление доступом и видимостью

Как мы видели на примерах программ в языке Java, модификаторы private и public размещаются отдельно для каждого поля данных и функции-члена.

Java вводит новый интересный модификатор с именем final. ╚Финальный╩ класс не может порождать подклассы. ╚Окончательный╩ метод не может переопределяться другим методом. Переменной экземпляра, описанной как final, нельзя присваивать значения. Использование ключевого слова final позволяет компилятору оптимизировать код.

Как и в C++, модификатор private в языке Java относится к классам, а не к экземплярам. Для экземпляров одного класса разрешен доступ к закрытым полям данных друг друга.

Другое средство управления областью видимости, предоставляемое языком Java,═≈ это пакеты. Пакет═содержит классы и интерфейсы. Пакеты служат для безконфликтного управления большими областями имен. Пакет описывается с помощью ключевого слова package, которое должно быть первым оператором в файле:

package packageName;

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

// получить тип данных foo из пакета bar
bar.foo newObj = new bar.foo();

Импортирование пакета делает имена всех его открытых классов и интерфейсов доступными так же, как если бы они были определены в текущем файле:

// импортировать все объекты и интерфейсы
// из пакета с именем bar
import bar.*;

При желании можно задать имена отдельных объектов и интерфейсов вместо универсального символа *.

// импортировать идентификатор foo
// из пакета bar
import bar.foo;

17.3.5. Видимость в Objective-C

Управление доступом и видимостью

В языке Objective-C объявление зкземплярных переменных должно помещаться в интерфейсное описание класса. Нельзя объявлять новые поля в разделе реализации (даже несмотря на то, что эти поля не станут частью интерфейса), поскольку они доступны только изнутри методов (в терминах языка C++ они являются защищенными). Видимость экземплярных переменных модифицируется с помощью ключевого слова @public, которое делает все поля, следующие за ключевым словом, доступными для пользователя. Например, следующий пример показывает описание интерфейса класса Ball, который представляет собой графический объект-шар. Положение шара задается координатами, хранящимися в полях данных x и y. Оно общедоступно, в то время как направление движения и энергия шара являются защищенными.

@interface Ball : Object
{
  double direction;
  double energy;

@public

  double x;
  double y;
}

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

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

17.4. Преднамеренное зацепление

Разделы

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

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

Один из способов избежать слишком сильной взаимозависимости между связанными компонентами═≈ использовать администратор зависимостей. Он является стандартной частью run-time библиотек в Smalltalk и Objective-C, но может быть легко сконструирован и для других языков (например, C++). Основная идея состоит в том, что администратор зависимостей действует как посредник, обслуживая список объектов и других компонент, от них зависящих. Модели требуется знать только об администраторе зависимостей. Объекты-отображения ╚регистрируют╩ себя с помощью администратора зависимостей, указывая при этом, что они зависят от объекта-модели. Впоследствии объект-модель при своем изменении посылает единственное сообщение администратору зависимостей. Тем самым последний узнает, что модель изменилась и все зависимые от нее компоненты должны быть оповещены об этом. Зависимые компоненты получают сообщения от администратора зависимостей, которые извещают, что модель изменилась и должны быть предприняты надлежащие действия.

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

Упражнения

Разделы

  1. Разработайте средство, обнаруживающее нарушения закона Деметера, в программах, написанных на вашем любимом объектно-ориентированном языке.
  2. Сильная форма закона Деметера запрещает доступ к экземплярам класса потомка. Опишите преимущества и недостатки этого ограничения. Рассмотрите такие моменты, как зацепление между классами и понятность кода программы.
  3. Не кажется ли вам, что сильная форма закона Деметера должна ограничивать доступ также и к глобальным переменным? Обоснуйте свое мнение. Обдумывая ответ, вы можете обратиться к статье Вульфа и Шоу [Wulf═1973].
  4. Какие еще можно предложить конкретные правила, аналогичные закону Деметера, такие, что:

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

  5. Защищенные данные (для клиентов-подклассов) занимают промежуточное положение по степени закрытости между открытыми и закрытыми полями. Представьте себе аналогичную промежуточную ступень между следующими положениями: ╚доступ к объекту разрешен кому угодно╩ и ╚доступ к объекту осуществляет только сам объект╩. Например: ╚экземпляры некоторого класса (и только они) могут иметь доступ ко внутреннему состоянию объекта╩. В языке C++, например, экземпляр класса имеет доступ к любому полю данных другого объекта того же класса, даже если эти поля описаны как private или protected. В других языках (например, в Smalltalk) такое не разрешается. Обсудите преимущества и недостатки каждого из подходов.
  6. Другая возможная вариация на тему правил видимости для клиентов-подклассов состоит в том, чтобы разрешить доступ к непосредственному родителю класса, но не к более удаленным предкам. Обсудите достоинства и недостатки этой идеи (см. [Snyder═1986]).
  7. Из рассмотренных нами языков только C++ и Delphi Pascal имеют явные средства для отделения свойств, доступных клиентам-подклассам, от свойств, доступных клиентам-пользователям. Тем не менее все языки имеют свой механизм описания намерений программиста. Зачастую используются структурированные комментарии (директивы компилятора) для передачи дополнительной информации системе, обслуживающей язык программирования. Опишите соглашения по комментированию, которые бы помогли определить уровень видимости. Затем охарактеризуйте алгоритм, который должен использоваться программным средством для обеспечения выполнения правил видимости.

Видимость и зависимость