128
ЧАСТЬ II Высококачественный код
АТД «шрифт» изначально предлагал такие сервисы:
currentFont.SetSize( sizeInPoints )
currentFont.SetBoldOn()
currentFont.SetBoldOff()
currentFont.SetItalicOn()
currentFont.SetItalicOff()
currentFont.SetTypeFace( faceName )
В среде, не являющейся объектно-ориентированной, эти методы не были бы свя- заны с классом и выглядели бы так:
SetCurrentFontSize( sizeInPoints )
SetCurrentFontBoldOn()
SetCurrentFontBoldOff()
SetCurrentFontItalicOn()
SetCurrentFontItalicOff()
SetCurrentFontTypeFace( faceName )
Если бы вы хотели работать с несколькими шрифтами одновременно, то должны были бы создать сервисы создания и удаления экземпляров шрифтов вроде этих:
CreateFont( fontId )
DeleteFont( fontId )
SetCurrentFont( fontId )
Идентификатор шрифта
fontId позволяет следить за несколькими шрифтами по мере их создания и использования. Что касается других операций, то в этом слу- чае вы можете выбирать один из трех вариантов реализации интерфейса АТД.
쐽
Вариант 1: явно указывать экземпляр данных при каждом обращении к серви- сам АТД. В этом случае «текущий шрифт (current font)» не требуется. В каждый метод, работающий со шрифтами, вы передаете
fontId. Методы АТД Font сле- дят за всеми данными шрифта, а клиентский код — лишь за идентификатором
fontId. Этот вариант требует, чтобы каждый метод, работающий со шрифтами,
принимал дополнительный параметр
fontId.
쐽
Вариант 2: явно предоставлять данные, используемые сервисами АТД. В дан- ном случае вы объявляете нужные АТД данные в каждом методе, использую- щем сервис АТД. Иначе говоря, вы создаете тип данных
Font, который переда- ете в каждый из сервисных методов АТД. Вы должны спроектировать сервис- ные методы АТД так, чтобы они использовали данные
Font, передаваемые в них при каждом вызове. При этом клиентский код не нуждается в идентификато- ре шрифта, потому что он следит за данными шрифтов сам. (Хотя данные типа
Font доступны напрямую, к ним надо обращаться только через сервисные ме- тоды АТД. Это называется поддержанием структуры «в закрытом виде».)
Преимущество этого подхода в том, что сервисным методам АТД не приходится просматривать информацию о шрифте, опираясь на его идентификатор. Есть и недостаток: такой способ предоставляет доступ к данным шрифта остальным ча- стям программы, из-за чего повышается вероятность того, что клиентский код будет использовать детали реализации АТД, которым следовало бы оставаться скрыты- ми внутри АТД.
1 ... 13 14 15 16 17 18 19 20 ... 104
ГЛАВА 6 Классы
137
Включение объявлений закрытых членов в заголовочный файл класса может по- казаться не таким уж и серьезным нарушением, но оно поощряет других програм- мистов изучать детали реализации. В нашем случае предполагается, что исполь- зовать адреса в клиентском коде нужно как типы
Address, однако, заглянув в заго- ловочный файл, можно узнать, что адреса хранятся как типы
String.
Общий способ решения этой проблемы описал Скотт Мейерс в разделе 34 книги
«Effective C++, 2d ed» (Meyers, 1998). Отделите интерфейс класса от его реализа- ции, после чего включите в объявление класса указатель на его реализацию, но не включайте других деталей реализации.
Пример сокрытия деталей реализации класса (C++)class Employee {
public:
Employee( ... );
FullName GetName() const;
String GetAddress() const;
private:
Детали реализации скрыты при помощи указателя.
EmployeeImplementation *m_implementation;
};
Теперь вы можете поместить детали реализации в класс
EmployeeImplementation,
который будет доступен только классу
Employee, но не использующему этот класс коду.
Если вы уже написали много кода, не используя этой методики, то можете найти преобразование кода неоправданным. Что ж, в этом случае,
читая код, раскры- вающий детали реализации, постарайтесь хотя бы сопротивляться соблазну изу- чить
закрытые разделы интерфейсов классов.
Не делайте предположений о клиентах класса Класс следует спроектиро- вать и реализовать так, чтобы он придерживался контракта, сформулированного посредством интерфейса. Выразив свои требования в интерфейсе, класс не дол- жен делать предположений о том, как этот интерфейс будет или не будет исполь- зоваться. Подобные комментарии указывают на то, что класс требует от своих клиентов больше, чем следует:
-- инициализируйте x, y и z значением 1.0, потому что
-- при инициализации значением 0.0 DerivedClass не работает
Избегайте использования дружественных классов Иногда — например, при реализации шаблона Состояние (State) — дисциплинированное использование дружественных классов помогает управлять сложностью (Gamma et al., 1995).
Однако обычно дружественные классы нарушают инкапсуляцию. Они увеличивают объем кода, о котором приходится думать в каждый конкретный момент време- ни, повышая тем самым сложность программы.
>
138
ЧАСТЬ II Высококачественный код
Не делайте метод открытым лишь потому, что он использует только
открытые методы То, что метод использует только открытые методы, не иг- рает особой роли. Лучше спросите себя, согласуется ли предоставление доступа к данному методу с абстракцией, формируемой интерфейсом.
Цените легкость чтения кода выше, чем удобство его написания Даже во время первоначальной разработки программы код приходится читать гораздо чаще, чем писать. Выгода от подхода, повышающего удобство написания кода за счет легкости его чтения, обманчива. При разработке интерфейсов классов это справедливо вдвойне. Даже если метод плохо согласуется с абстракцией интер- фейса, иногда так и тянет включить его в интерфейс, чтобы облегчить работу над конкретным клиентом класса. Однако это первый шаг к беде, и о нем лучше даже не помышлять.
Очень, очень настороженно относитесь к семанти-
ческим нарушениям инкапсуляции Когда-то мне каза- лось, что, научившись избегать синтаксических ошибок, я обрету покой. Но вскоре я обнаружил, что это просто от- крыло передо мной дверь в мир совершенно новых оши- бок, большинство которых диагностировать и исправлять сложнее, чем синтаксические.
Аналогичные отношения имеют место между синтаксической и семантической инкапсуляцией. С точки зрения синтаксиса, не совать нос во внутренние дела другого класса относительно легко: достаточно просто объявить его внутренние методы и данные закрытыми. Достичь семантической инкапсуляции гораздо слож- нее. Вот несколько примеров того, как вы можете нарушить инкапсуляцию семан- тически. Вы можете:
쐽
решить не вызывать метод
InitializeOperations() Класса A, потому что метод
PerformFirstOperation() Класса A вызывает его автоматически;
쐽
не вызвать метод
database.Connect() перед вызовом метода employee.Retrieve(
database ), потому что знаете, что при отсутствии соединения с БД метод
employee.Retrieve() его установит;
쐽
не вызвать метод
Terminate() Класса A, так как знаете, что метод PerformFinal-
Operation() Класса A уже вызвал его;
쐽
использовать указатель или ссылку на Объект B, созданный Объектом A, даже после выхода Объекта A из области видимости, потому что знаете, что Объект
A хранит Объект B в статическом хранилище, вследствие чего Объект B все еще будет корректным;
쐽
использовать константу
MAXIMUM_ELEMENTS Класса B вместо константы MAXI-
MUM_ELEMENTS Класса A, потому что знаете, что они имеют одинаковые зна- чения.
Со всеми этими примерами связана одна и та же проблема: зависимость клиентского кода от закрытой реализации класса, а не от его открытого интерфейса. Каждый раз, когда вы смотрите на реализацию класса, что- бы узнать, как его использовать, вы программируете не в соответствии с интер- фейсом, а
сквозь интерфейс в соответствии с реализацией. Программирование
Если для понимания того, что происходит, нужно увидеть ре- ализацию, это не абстракция.
Ф. Дж. Плоджер
(P. J. Plauger)
ГЛАВА 6 Классы
139
сквозь интерфейс разрушает инкапсуляцию, а вскоре к ней присоединяется и абстракция.
Если исключительно по документации интерфейса разобраться с использовани- ем класса не удается, изучение реализации класса по исходному коду
не будет грамотным решением. Это хорошая инициатива, но плохое решение. Вы посту- пите правильно, если свяжетесь с автором класса и скажете ему: «Я не могу по- нять, как использовать этот класс». Автор класса поступит правильно, если
не от- ветит вам, а изучит файл интерфейса, изменит соответствующую документацию,
зарегистрирует файл в общих исходных кодах проекта и скажет: «Посмотрите,
поймете ли вы работу класса сейчас». Желательно, чтобы этот диалог происхо- дил в самом коде интерфейса: так он будет сохранен для будущих программис- тов. Если диалог будет происходить исключительно в вашем уме, это внесет тон- кие семантические зависимости в код клиентов класса. Если же он будет межлич- ностным, выгоду сможете извлечь только вы, и больше никто — это некрасиво.
Остерегайтесь слишком жесткого сопряжения «Сопряжение» (coupling)
характеризует силу связи между двумя классами. Как правило, чем сопряжение слабее, тем лучше. Из этого можно вывести несколько общих правил:
쐽
минимизируйте доступность классов и их членов;
쐽
избегайте дружественных классов, потому что они связаны жестко;
쐽
делайте данные базового класса закрытыми, а не защищенными: это ослабля- ет сопряжение производных классов с базовым;
쐽
не включайте данные-члены в открытый интерфейс класса;
쐽
остерегайтесь семантических нарушений инкапсуляции;
쐽
соблюдайте «Правило Деметры» (см. раздел 6.3).
Сопряжение идет рука об руку с абстракцией и инкапсуляцией. Жесткое сопря- жение наблюдается при неудачной абстракции или нарушениях инкапсуляции. Если класс предлагает неполный набор услуг, другие методы могут попытаться прочи- тать или записать его данные непосредственно. Это открывает класс, превращая его из черного ящика в прозрачный, и практически устраняет инкапсуляцию.
6.3. Вопросы проектирования и реализации
Для создания высококачественной программы недостаточно определить удачные интерфейсы классов — не менее важно грамотно спроектировать и реализовать внутреннее устройство классов. В этом разделе мы обсудим вопросы, связанные с включением, наследованием, методами/данными-членами, сопряжением клас- сов, конструкторами, а также объектами-значениями и объектами-ссылками.
Включение (отношение «содержит»)
Сущность включения (containment) проста: один класс содержит прими- тивный элемент данных или другой класс. Наследованию в литературе уделяют гораздо больше внимания, но это объясняется его сложностью и подверженностью ошибкам, а не тем, что оно лучше включения. Включение —
один из главных инструментов объектно-ориентированного программирования.
140
ЧАСТЬ II Высококачественный код
Реализуйте с помощью включения отношение «содержит» Включение мож- но рассматривать как отношение «содержит». Например, объект «сотрудник» мо- жет «содержать» фамилию, номер телефона, идентификационный номер налого- плательщика и т. д. Это отношение можно реализовать, сделав фамилию, номер телефона и номер налогоплательщика данными-членами класса
Employee.
В самом крайнем случае реализуйте отношение «содержит» при помощи
закрытого наследования Иногда включение не получается реализовать, делая один объект членом другого. Некоторые эксперты советуют при этом выполнять закрытое наследование класса-контейнера от класса, который должен в нем со- держаться (Meyers, 1998; Sutter, 2000). Главным мотивом такого решения является предоставление классу-контейнеру доступа к защищенным методам/данным-чле- нам содержащегося в нем класса. На практике этот подход устанавливает слиш- ком близкие отношения между дочерним и родительским классом, нарушая ин- капсуляцию. Обычно это указывает на ошибки проектирования, которые следует решить иначе, не прибегая к закрытому наследованию.
Настороженно относитесь к классам, содержащим более семи элементов
данных-членов При выполнении других заданий человек может удерживать в памяти 7±2 дискретных элементов (Miller, 1956). Если класс содержит более семи элементов данных-членов, подумайте, не разделить ли его на несколько менее крупных классов (Riel, 1996). Можете ориентироваться на верхнюю границу диа- пазона «7±2», если данные-члены являются примитивными типами, такими как целые числа и строки, и на нижнюю, если они являются сложными объектами.
Наследование (отношение «является»)
Наследование подразумевает, что один класс является более специализированным вариантом другого класса. Цель наследования — создать более простой код, что достигается путем определения базового класса, идентифицирующего общие эле- менты двух или более производных классов. Общими элементами могут быть интерфейсы методов, их реализация, данные-члены или типы данных. Наследо- вание помогает избегать повторения кода и данных в нескольких местах, цент- рализуя их в базовом классе.
Планируя использовать наследование, вы должны принять несколько решений.
쐽
Будет ли конкретный метод-член доступен производным классам? Будет ли он иметь реализацию по умолчанию? Можно ли будет переопределить его реа- лизацию по умолчанию?
쐽
Будут ли конкретные данные-члены (в том числе переменные, именованные константы, перечисления и т. д.) доступны производным классам?
Ниже аспекты этих решений обсуждаются подробнее.
Реализуйте при помощи открытого наследования
отношение «является» Если программист решает создать новый класс путем наследования его от существующего класса, он по сути говорит, что новый класс «является» бо- лее специализированной версией существующего класса.
Самое важное правило объект- но-ориентированного програм- мирования на C++ таково: от- крытое наследование означает
«является». Запомните это.
Скотт Мейерс
(Scott Meyers)