Файл: Руководство по стилю программирования и конструированию по.pdf
ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 30.11.2023
Просмотров: 764
Скачиваний: 2
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
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 Классы
129
쐽
Вариант 3: использовать неявные экземпляры (с большой осторожностью). Вы должны создать новый сервис — скажем,
SetCurrentFont ( fontId ), — при вызо- ве которого заданный экземпляр шрифта делается текущим. После этого все остальные сервисы используют текущий шрифт, благодаря чему в них не нуж- но передавать параметр
fontId. При разработке простых приложений такой под- ход может облегчить использование нескольких экземпляров данных. В слож- ных приложениях подобная зависимость от состояния в масштабе всей сис- темы подразумевает, что вы должны следить за текущим экземпляром шрифта во всем коде, вызывающем методы
Font; разумеется, сложность программы при этом повышается. Каким бы ни был размер приложения, всегда можно найти более удачные альтернативы данному подходу.
Внутри АТД вы можете реализовать работу с несколькими экземплярами данных как угодно, но вне его при использовании языка, не являющегося объектно-ори- ентированным, возможны только три указанных варианта.
АТД и классы
Абстрактные типы данных лежат в основе концепции классов. В языках, поддержи- вающих классы, каждый АТД можно реализовать как отдельный класс. Однако обыч- но с классами связывают еще две концепции: наследование и полиморфизм. Може- те рассматривать класс как АТД, поддерживающий наследование и полиморфизм.
6.2. Качественные интерфейсы классов
Первый и, наверное, самый важный этап разработки высококачественного клас- са — создание адекватного интерфейса. Это подразумевает, что интерфейс дол- жен представлять хорошую абстракцию, скрывающую детали реализации класса.
Хорошая абстракция
Как я говорил в подразделе «Определите согласованные абстракции» раздела 5.3,
под абстракцией понимается представление сложной операции в упрощенной форме. Интерфейс класса — это абстракция реализации класса, скрытой за ин- терфейсом. Интерфейс класса должен предоставлять группу методов, четко согла- сующихся друг с другом.
Рассмотрим для примера класс «сотрудник». Он может содержать такие данные,
как фамилия сотрудника, адрес, номер телефона и т. д., и предлагать методы ини- циализации и использования этих данных. Вот как мог бы выглядеть такой класс:
Пример интерфейса, формирующего хорошую абстракцию (C++)
class Employee {
public:
// открытые конструкторы и деструкторы
Employee();
Employee(
FullName name,
String address,
String workPhone,
Перекрестная ссылка Примеры кода в этой книге отформати- рованы с использованием кон- венции, поддерживающей сход- ство стилей между нескольки- ми языками. Об этой конвенции
(и разных стилях кодирования)
см. подраздел «Программирова- ние с использованием несколь- ких языков» раздела 11.4.
130
ЧАСТЬ II Высококачественный код
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
virtual Employee();
// открытые методы
FullName GetName() const;
String GetAddress() const;
String GetWorkPhone() const;
String GetHomePhone() const;
TaxId GetTaxIdNumber() const;
JobClassification GetJobClassification() const;
private:
};
Внутри этот класс может иметь дополнительные методы и данные, поддержива- ющие работу этих сервисов, но пользователям класса знать о них не нужно. Пред- ставляемая интерфейсом этого класса абстракция великолепна, потому что все методы интерфейса служат единой согласованной цели.
Интерфейс, представляющий плохую абстракцию, содержал бы набор разнород- ных методов, например:
Пример интерфейса, формирующего
плохую абстракцию (C++)
class Program {
public:
// открытые методы void InitializeCommandStack();
void PushCommand( Command command );
Command PopCommand();
void ShutdownCommandStack();
void InitializeReportFormatting();
void FormatReport( Report report );
void PrintReport( Report report );
void InitializeGlobalData();
void ShutdownGlobalData();
private:
};
Похоже, этот класс содержит методы работы со стеком команд, форматирования отчетов, печати отчетов и инициализации глобальных данных. Трудно увидеть связь между стеком команд, обработкой отчетов и глобальными данными. Интерфейс такого класса не формирует согласованную абстракцию, и класс обладает плохой
ГЛАВА 6 Классы
131
связностью. В данном случае методы следует реорганизовать в более четкие классы,
интерфейсы которых будут представлять более удачные абстракции.
Если бы эти методы были частью класса
Program, для формирования согласован- ной абстракции их можно было бы изменить так:
Пример интерфейса, формирующего более удачную абстракцию (C++)
class Program {
public:
// открытые методы void InitializeUserInterface();
void ShutDownUserInterface();
void InitializeReports();
void ShutDownReports();
private:
};
В ходе очистки интерфейса одни его методы были перемещены в более подходя- щие классы, а другие были преобразованы в закрытые методы, используемые методом
InitializeUserInterface() и другими методами.
Данный способ оценки абстракции класса основан на изучении открытых методов класса, т. е. его интерфейса. Однако из того, что класс в целом формирует хорошую абстракцию, вовсе не следует, что его отдельные методы также представляют удач- ные абстракции. Рекомендации по проектированию методов см. в разделе 7.2.
Чтобы ваши классы имели высококачественные абстрактные интерфейсы, соблю- дайте при их проектировании следующие принципы.
Выражайте в интерфейсе класса согласованный уровень абстракции
Классы полезно рассматривать как механизмы реализации абстрактных типов дан- ных, описанных в разделе 6.1. В идеале каждый класс должен быть реализацией только одного АТД. Если класс реализует более одного АТД или если вам не уда- ется определить, реализацией какого АТД класс является, самое время реоргани- зовать класс в один или несколько хорошо определенных АТД.
Так, следующий класс имеет несогласованный интерфейс, потому что формируе- мый им уровень абстракции непостоянен:
Пример интерфейса, включающего разные
уровни абстракции (C++)
class EmployeeCensus: public ListContainer {
public:
// открытые методы
132
ЧАСТЬ II Высококачественный код
Абстракция, формируемая этими методами, относится к уровню «employee» (сотрудник).
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );
Абстракция, формируемая этими методами, относится к уровню «list» (список).
Employee NextItemInList();
Employee FirstItem();
Employee LastItem();
private:
};
Этот класс представляет два АТД:
Employee и ListContainer (список-контейнер).
Подобные смешанные абстракции часто возникают, когда программист реализу- ет класс при помощи класса-контейнера или других библиотечных классов и не скрывает этот факт. Спросите себя, должна ли информация об использовании класса-контейнера быть частью абстракции. Обычно это является деталью реали- зации, которую следует скрыть от остальных частей программы, например так:
Пример интерфейса, формирующего согласованную абстракцию (C++)
class EmployeeCensus {
public:
// открытые методы
Абстракция, формируемая всеми этими методами, теперь относится к уровню «employee».
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );
Employee NextEmployee();
Employee FirstEmployee();
Employee LastEmployee();
private:
Тот факт, что класс использует библиотеку ListContainer, теперь скрыт.
ListContainer m_EmployeeList;
};
Программисты могут утверждать, что наследование от
ListContainer удобно, потому что оно поддерживает полиморфизм, позволяя создать внешний метод поиска или сортировки, принимающий объект
ListContainer. Но этот аргумент не проходит главный тест на уместность наследования: «Используется ли наследование толь- ко для моделирования отношения „является“?» Наследование класса
EmployeeCensus
(каталог личных дел сотрудников) от класса
ListContainer означало бы, что Employee-
Census «является» ListContainer, что, очевидно, неверно. Если абстракция объекта
EmployeeCensus заключается в том, что он поддерживает поиск или сортировку,
>
>
>
>
ГЛАВА 6 Классы
133
эти возможности должны быть явными согласованными частями интерфейса класса.
Если представить открытые методы класса как люк, предотвращающий попадание воды в подводную лодку, несогласованные открытые методы — это щели. Вода не будет протекать через них так быстро, как через открытый люк, но позже лодка все же потонет. На практике при смешении уровней абстракции именно это и проис- ходит. По мере изменений программы смешанные уровни абстракции делают ее все менее и менее понятной, пока в итоге код не станет совсем загадочным.
Убедитесь, что вы понимаете, реализацией какой абстракции
является класс Некоторые классы очень похожи, поэтому при разра- ботке класса нужно понимать, какую абстракцию должен представлять его интерфейс. Однажды я работал над программой, которая должна была под- держивать редактирование информации в табличном формате. Сначала мы хо- тели использовать простой элемент управления «grid» (сетка), но доступные эле- менты управления этого типа не позволяли закрашивать ячейки ввода данных в другой цвет, поэтому мы выбрали элемент управления «spreadsheet» (электронная таблица), который такую возможность поддерживал.
Элемент управления «электронная таблица» был гораздо сложнее «сетки» и пре- доставлял около 150 методов в сравнении с 15 методами «сетки». Так как наша цель заключалась в использовании «сетки», а не «электронной таблицы», мы поручили одному программисту написать класс-оболочку, который скрывал бы тот факт, что мы подменили один элемент управления другим. Он поворчал по поводу ненуж- ных затрат и бюрократии, ушел и вернулся через пару дней с классом-оболочкой,
который честно предоставлял все 150 методов «электронной таблицы».
Но нам было нужно не это — нам требовался интерфейс «сетки», инкапсулирую- щий тот факт, что за кулисами мы использовали гораздо более сложную «элект- ронную таблицу». Программисту следовало предоставить доступ только к 15 ме- тодам «сетки» и еще одному, шестнадцатому методу, поддерживающему закраши- вание ячеек. Открыв доступ ко всем 150 методам, программист подверг нас риску того, что после нескольких изменений реализации класса нам в итоге придется поддерживать все 150 открытых методов. Он не смог обеспечить нужную нам инкапсуляцию и проделал гораздо больше работы, чем стоило.
В зависимости от конкретных обстоятельств оптимальной абстракцией может оказаться как «сетка», так и «электронная таблица». Если приходится выбирать между двумя похожими абстракциями, убедитесь, что выбор правилен.
Предоставляйте методы вместе с противоположными им методами
Большинство операций имеет соответствующие противоположные операции. Если одна из операций включает свет, вам, вероятно, понадобится и операция, его вы- ключающая. Если одна операция добавляет элемент в список, элементы скорее всего нужно будет и удалять. Если одна операция активизирует элемент меню, вторая,
наверное, должна будет его деактивизировать. При проектировании класса про- верьте каждый открытый метод на предмет того, требуется ли вам его противо- положность. Создавать противоположные методы, не имея на то причин, не сле- дует, но проверить их целесообразность нужно.
134
ЧАСТЬ II Высококачественный код
Убирайте постороннюю информацию в другие классы Иногда вы будете обнаруживать, что одни методы класса работают с одной половиной данных, а другие — с другой. Это значит, что вы имеете дело с двумя классами, скрывающи- мися под маской одного. Разделите их!
По мере возможности делайте интерфейсы программными, а не семан-
тическими Каждый интерфейс состоит из программной и семантической ча- стей. Первая включает типы данных и другие атрибуты интерфейса, которые могут быть проверены компилятором. Вторая складывается из предположений об ис- пользовании интерфейса, которые компилятор проверить не может. Семантический интерфейс может включать такие соображения, как «Метод А должен быть выз- ван перед Методом B» или «Метод А вызовет ошибку, если переданный в него Эле- мент Данных 1 не будет перед этим инициализирован». Семантический интерфейс следует документировать в комментариях, но вообще интерфейсы должны как можно меньше зависеть от документации. Любой аспект интерфейса, который не может быть проверен компилятором, является потенциальным источником оши- бок. Старайтесь преобразовывать семантические элементы интерфейса в программ- ные, используя утверждения (assertions) или иными способами.
Опасайтесь нарушения целостности интерфейса при
изменении класса При модификации и расширении клас- са часто обнаруживается дополнительная нужная функци- ональность, которая не совсем хорошо соответствует интер- фейсу первоначального класса, но плохо поддается реализации иным образом. Так,
класс
Employee может превратиться во что-нибудь вроде:
Пример интерфейса, изуродованного при сопровождении
программы (C++)
class Employee {
public:
// открытые методы
FullName GetName() const;
Address GetAddress() const;
PhoneNumber GetWorkPhone() const;
bool IsJobClassificationValid( JobClassification jobClass );
bool IsZipCodeValid( Address address );
bool IsPhoneNumberValid( PhoneNumber phoneNumber );
SqlQuery GetQueryToCreateNewEmployee() const;
SqlQuery GetQueryToModifyEmployee() const;
SqlQuery GetQueryToRetrieveEmployee() const;
private:
};
То, что начиналось как ясная абстракция, превратилось в смесь почти несогласо- ванных методов. Между сотрудниками и методами, проверяющими корректность
Перекрестная ссылка О поддер- жании качества кода при его изменении см. главу 24.
ГЛАВА 6 Классы
135
почтового индекса, номера телефона или ставки зарплаты (job classification), нет логической связи. Методы, предоставляющие доступ к деталям SQL-запросов, от- носятся к гораздо более низкому уровню абстракции, чем класс
Employee, нару- шая общую абстракцию класса.
Не включайте в класс открытые члены, плохо согласующиеся с абстрак-
цией интерфейса Добавляя новый метод в интерфейс класса, всегда спраши- вайте себя: «Согласуется ли этот метод с абстракцией, формируемой существую- щим интерфейсом?» Если нет, найдите другой способ внесения изменения, позво- ляющий сохранить согласованность абстракции.
Рассматривайте абстракцию и связность вместе Понятия абстракции и связности (cohesion) тесно связаны: интерфейс класса, представляющий хорошую абстракцию, обычно отличается высокой связностью. И наоборот: классы, имею- щие высокую связность, обычно представляют хорошие абстракции, хотя эта связь выражена слабее.
Я обнаружил, что при повышенном внимании к абстракции, формируемой ин- терфейсом класса, проект класса получается более удачным, чем при концентра- ции на связности класса. Если вы видите, что класс имеет низкую связность и не знаете, как это исправить, спросите себя, представляет ли он согласованную аб- стракцию.
Хорошая инкапсуляция
Как я уже говорил в разделе 5.3, инкапсуляция является бо- лее строгой концепцией, чем абстракция. Абстракция по- могает управлять сложностью, предоставляя модели, позво- ляющие игнорировать детали реализации. Инкапсуляция не позволяет узнать детали реализации, даже если вы этого захотите.
Две этих концепции связаны: без инкапсуляции абстракция обычно разрушается.
По своему опыту могу сказать, что вы или имеете и абстракцию, и инкапсуляцию,
или не имеете ни того, ни другого. Промежуточных вариантов нет.
Минимизируйте доступность классов и их членов Ми- нимизация доступности — одно из нескольких правил, под- держивающих инкапсуляцию. Если вы не можете понять,
каким делать конкретный метод: открытым, закрытым или защищенным, — некоторые авторы советуют выбирать са- мый строгий уровень защиты, который работает (Meyers,
1998; Bloch, 2001). По-моему, это прекрасное правило, но мне кажется, что еще важнее спросить себя: «Какой вари- ант лучше всего сохраняет целостность абстракции интер- фейса?» Если предоставление доступа к методу согласуется с абстракцией, сделайте его открытым. Если вы не уверены, скрыть больше обычно предпочтительнее, чем скрыть меньше.
Не делайте данные-члены открытыми Предоставление доступа к данным- членам нарушает инкапсуляцию и ограничивает контроль над абстракцией. Как
Перекрестная ссылка Об инкап- суляции см. подраздел «Инкап- сулируйте детали реализации»
раздела 5.3.
Самым важным отличием хоро- шо спроектированного модуля от плохо спроектированного яв- ляется степень, в которой мо- дуль скрывает свои внутренние данные и другие детали реали- зации от других модулей.
Джошуа Блох (Joshua Bloch)
136
ЧАСТЬ II Высококачественный код указывает Артур Риэль, класс
Point (точка), который предоставляет доступ к дан- ным:
float x;
float y;
float z;
нарушает инкапсуляцию, потому что клиентский код может свободно делать с данными
Point что угодно, при этом сам класс может даже не узнать об их изме- нении (Riel, 1996). В то же время класс
Point, включающий члены:
float GetX();
float GetY();
float GetZ();
void SetX( float x );
void SetY( float y );
void SetZ( float z );
поддерживает прекрасную инкапсуляцию. Вы не имеете понятия о том, реализо- ваны ли данные как
float x, y и z, хранит ли класс Point эти элементы как double,
преобразуя их в
float, или же он хранит их на Луне и получает через спутник.
Не включайте в интерфейс класса закрытые детали реализации Истинная инкапсуляция не позволяла бы узнать детали реализации вообще. Они были бы скрыты и в прямом, и в переносном смыслах. Однако популярные языки — в том числе C++ — требуют, чтобы программисты раскрывали детали реализации в интерфейсе класса, например:
Пример обнародования деталей реализации класса (C++)
class Employee {
public:
Employee(
FullName name,
String address,
String workPhone,
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
FullName GetName() const;
String GetAddress() const;
private:
Обнародованные детали реализации.
String m_Name;
String m_Address;
int m_jobClass;
};
>
1 ... 14 15 16 17 18 19 20 21 ... 104
130
ЧАСТЬ II Высококачественный код
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
virtual Employee();
// открытые методы
FullName GetName() const;
String GetAddress() const;
String GetWorkPhone() const;
String GetHomePhone() const;
TaxId GetTaxIdNumber() const;
JobClassification GetJobClassification() const;
private:
};
Внутри этот класс может иметь дополнительные методы и данные, поддержива- ющие работу этих сервисов, но пользователям класса знать о них не нужно. Пред- ставляемая интерфейсом этого класса абстракция великолепна, потому что все методы интерфейса служат единой согласованной цели.
Интерфейс, представляющий плохую абстракцию, содержал бы набор разнород- ных методов, например:
Пример интерфейса, формирующего
плохую абстракцию (C++)
class Program {
public:
// открытые методы void InitializeCommandStack();
void PushCommand( Command command );
Command PopCommand();
void ShutdownCommandStack();
void InitializeReportFormatting();
void FormatReport( Report report );
void PrintReport( Report report );
void InitializeGlobalData();
void ShutdownGlobalData();
private:
};
Похоже, этот класс содержит методы работы со стеком команд, форматирования отчетов, печати отчетов и инициализации глобальных данных. Трудно увидеть связь между стеком команд, обработкой отчетов и глобальными данными. Интерфейс такого класса не формирует согласованную абстракцию, и класс обладает плохой
ГЛАВА 6 Классы
131
связностью. В данном случае методы следует реорганизовать в более четкие классы,
интерфейсы которых будут представлять более удачные абстракции.
Если бы эти методы были частью класса
Program, для формирования согласован- ной абстракции их можно было бы изменить так:
Пример интерфейса, формирующего более удачную абстракцию (C++)
class Program {
public:
// открытые методы void InitializeUserInterface();
void ShutDownUserInterface();
void InitializeReports();
void ShutDownReports();
private:
};
В ходе очистки интерфейса одни его методы были перемещены в более подходя- щие классы, а другие были преобразованы в закрытые методы, используемые методом
InitializeUserInterface() и другими методами.
Данный способ оценки абстракции класса основан на изучении открытых методов класса, т. е. его интерфейса. Однако из того, что класс в целом формирует хорошую абстракцию, вовсе не следует, что его отдельные методы также представляют удач- ные абстракции. Рекомендации по проектированию методов см. в разделе 7.2.
Чтобы ваши классы имели высококачественные абстрактные интерфейсы, соблю- дайте при их проектировании следующие принципы.
Выражайте в интерфейсе класса согласованный уровень абстракции
Классы полезно рассматривать как механизмы реализации абстрактных типов дан- ных, описанных в разделе 6.1. В идеале каждый класс должен быть реализацией только одного АТД. Если класс реализует более одного АТД или если вам не уда- ется определить, реализацией какого АТД класс является, самое время реоргани- зовать класс в один или несколько хорошо определенных АТД.
Так, следующий класс имеет несогласованный интерфейс, потому что формируе- мый им уровень абстракции непостоянен:
Пример интерфейса, включающего разные
уровни абстракции (C++)
class EmployeeCensus: public ListContainer {
public:
// открытые методы
132
ЧАСТЬ II Высококачественный код
Абстракция, формируемая этими методами, относится к уровню «employee» (сотрудник).
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );
Абстракция, формируемая этими методами, относится к уровню «list» (список).
Employee NextItemInList();
Employee FirstItem();
Employee LastItem();
private:
};
Этот класс представляет два АТД:
Employee и ListContainer (список-контейнер).
Подобные смешанные абстракции часто возникают, когда программист реализу- ет класс при помощи класса-контейнера или других библиотечных классов и не скрывает этот факт. Спросите себя, должна ли информация об использовании класса-контейнера быть частью абстракции. Обычно это является деталью реали- зации, которую следует скрыть от остальных частей программы, например так:
Пример интерфейса, формирующего согласованную абстракцию (C++)
class EmployeeCensus {
public:
// открытые методы
Абстракция, формируемая всеми этими методами, теперь относится к уровню «employee».
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );
Employee NextEmployee();
Employee FirstEmployee();
Employee LastEmployee();
private:
Тот факт, что класс использует библиотеку ListContainer, теперь скрыт.
ListContainer m_EmployeeList;
};
Программисты могут утверждать, что наследование от
ListContainer удобно, потому что оно поддерживает полиморфизм, позволяя создать внешний метод поиска или сортировки, принимающий объект
ListContainer. Но этот аргумент не проходит главный тест на уместность наследования: «Используется ли наследование толь- ко для моделирования отношения „является“?» Наследование класса
EmployeeCensus
(каталог личных дел сотрудников) от класса
ListContainer означало бы, что Employee-
Census «является» ListContainer, что, очевидно, неверно. Если абстракция объекта
EmployeeCensus заключается в том, что он поддерживает поиск или сортировку,
>
>
>
>
ГЛАВА 6 Классы
133
эти возможности должны быть явными согласованными частями интерфейса класса.
Если представить открытые методы класса как люк, предотвращающий попадание воды в подводную лодку, несогласованные открытые методы — это щели. Вода не будет протекать через них так быстро, как через открытый люк, но позже лодка все же потонет. На практике при смешении уровней абстракции именно это и проис- ходит. По мере изменений программы смешанные уровни абстракции делают ее все менее и менее понятной, пока в итоге код не станет совсем загадочным.
Убедитесь, что вы понимаете, реализацией какой абстракции
является класс Некоторые классы очень похожи, поэтому при разра- ботке класса нужно понимать, какую абстракцию должен представлять его интерфейс. Однажды я работал над программой, которая должна была под- держивать редактирование информации в табличном формате. Сначала мы хо- тели использовать простой элемент управления «grid» (сетка), но доступные эле- менты управления этого типа не позволяли закрашивать ячейки ввода данных в другой цвет, поэтому мы выбрали элемент управления «spreadsheet» (электронная таблица), который такую возможность поддерживал.
Элемент управления «электронная таблица» был гораздо сложнее «сетки» и пре- доставлял около 150 методов в сравнении с 15 методами «сетки». Так как наша цель заключалась в использовании «сетки», а не «электронной таблицы», мы поручили одному программисту написать класс-оболочку, который скрывал бы тот факт, что мы подменили один элемент управления другим. Он поворчал по поводу ненуж- ных затрат и бюрократии, ушел и вернулся через пару дней с классом-оболочкой,
который честно предоставлял все 150 методов «электронной таблицы».
Но нам было нужно не это — нам требовался интерфейс «сетки», инкапсулирую- щий тот факт, что за кулисами мы использовали гораздо более сложную «элект- ронную таблицу». Программисту следовало предоставить доступ только к 15 ме- тодам «сетки» и еще одному, шестнадцатому методу, поддерживающему закраши- вание ячеек. Открыв доступ ко всем 150 методам, программист подверг нас риску того, что после нескольких изменений реализации класса нам в итоге придется поддерживать все 150 открытых методов. Он не смог обеспечить нужную нам инкапсуляцию и проделал гораздо больше работы, чем стоило.
В зависимости от конкретных обстоятельств оптимальной абстракцией может оказаться как «сетка», так и «электронная таблица». Если приходится выбирать между двумя похожими абстракциями, убедитесь, что выбор правилен.
Предоставляйте методы вместе с противоположными им методами
Большинство операций имеет соответствующие противоположные операции. Если одна из операций включает свет, вам, вероятно, понадобится и операция, его вы- ключающая. Если одна операция добавляет элемент в список, элементы скорее всего нужно будет и удалять. Если одна операция активизирует элемент меню, вторая,
наверное, должна будет его деактивизировать. При проектировании класса про- верьте каждый открытый метод на предмет того, требуется ли вам его противо- положность. Создавать противоположные методы, не имея на то причин, не сле- дует, но проверить их целесообразность нужно.
134
ЧАСТЬ II Высококачественный код
Убирайте постороннюю информацию в другие классы Иногда вы будете обнаруживать, что одни методы класса работают с одной половиной данных, а другие — с другой. Это значит, что вы имеете дело с двумя классами, скрывающи- мися под маской одного. Разделите их!
По мере возможности делайте интерфейсы программными, а не семан-
тическими Каждый интерфейс состоит из программной и семантической ча- стей. Первая включает типы данных и другие атрибуты интерфейса, которые могут быть проверены компилятором. Вторая складывается из предположений об ис- пользовании интерфейса, которые компилятор проверить не может. Семантический интерфейс может включать такие соображения, как «Метод А должен быть выз- ван перед Методом B» или «Метод А вызовет ошибку, если переданный в него Эле- мент Данных 1 не будет перед этим инициализирован». Семантический интерфейс следует документировать в комментариях, но вообще интерфейсы должны как можно меньше зависеть от документации. Любой аспект интерфейса, который не может быть проверен компилятором, является потенциальным источником оши- бок. Старайтесь преобразовывать семантические элементы интерфейса в программ- ные, используя утверждения (assertions) или иными способами.
Опасайтесь нарушения целостности интерфейса при
изменении класса При модификации и расширении клас- са часто обнаруживается дополнительная нужная функци- ональность, которая не совсем хорошо соответствует интер- фейсу первоначального класса, но плохо поддается реализации иным образом. Так,
класс
Employee может превратиться во что-нибудь вроде:
Пример интерфейса, изуродованного при сопровождении
программы (C++)
class Employee {
public:
// открытые методы
FullName GetName() const;
Address GetAddress() const;
PhoneNumber GetWorkPhone() const;
bool IsJobClassificationValid( JobClassification jobClass );
bool IsZipCodeValid( Address address );
bool IsPhoneNumberValid( PhoneNumber phoneNumber );
SqlQuery GetQueryToCreateNewEmployee() const;
SqlQuery GetQueryToModifyEmployee() const;
SqlQuery GetQueryToRetrieveEmployee() const;
private:
};
То, что начиналось как ясная абстракция, превратилось в смесь почти несогласо- ванных методов. Между сотрудниками и методами, проверяющими корректность
Перекрестная ссылка О поддер- жании качества кода при его изменении см. главу 24.
ГЛАВА 6 Классы
135
почтового индекса, номера телефона или ставки зарплаты (job classification), нет логической связи. Методы, предоставляющие доступ к деталям SQL-запросов, от- носятся к гораздо более низкому уровню абстракции, чем класс
Employee, нару- шая общую абстракцию класса.
Не включайте в класс открытые члены, плохо согласующиеся с абстрак-
цией интерфейса Добавляя новый метод в интерфейс класса, всегда спраши- вайте себя: «Согласуется ли этот метод с абстракцией, формируемой существую- щим интерфейсом?» Если нет, найдите другой способ внесения изменения, позво- ляющий сохранить согласованность абстракции.
Рассматривайте абстракцию и связность вместе Понятия абстракции и связности (cohesion) тесно связаны: интерфейс класса, представляющий хорошую абстракцию, обычно отличается высокой связностью. И наоборот: классы, имею- щие высокую связность, обычно представляют хорошие абстракции, хотя эта связь выражена слабее.
Я обнаружил, что при повышенном внимании к абстракции, формируемой ин- терфейсом класса, проект класса получается более удачным, чем при концентра- ции на связности класса. Если вы видите, что класс имеет низкую связность и не знаете, как это исправить, спросите себя, представляет ли он согласованную аб- стракцию.
Хорошая инкапсуляция
Как я уже говорил в разделе 5.3, инкапсуляция является бо- лее строгой концепцией, чем абстракция. Абстракция по- могает управлять сложностью, предоставляя модели, позво- ляющие игнорировать детали реализации. Инкапсуляция не позволяет узнать детали реализации, даже если вы этого захотите.
Две этих концепции связаны: без инкапсуляции абстракция обычно разрушается.
По своему опыту могу сказать, что вы или имеете и абстракцию, и инкапсуляцию,
или не имеете ни того, ни другого. Промежуточных вариантов нет.
Минимизируйте доступность классов и их членов Ми- нимизация доступности — одно из нескольких правил, под- держивающих инкапсуляцию. Если вы не можете понять,
каким делать конкретный метод: открытым, закрытым или защищенным, — некоторые авторы советуют выбирать са- мый строгий уровень защиты, который работает (Meyers,
1998; Bloch, 2001). По-моему, это прекрасное правило, но мне кажется, что еще важнее спросить себя: «Какой вари- ант лучше всего сохраняет целостность абстракции интер- фейса?» Если предоставление доступа к методу согласуется с абстракцией, сделайте его открытым. Если вы не уверены, скрыть больше обычно предпочтительнее, чем скрыть меньше.
Не делайте данные-члены открытыми Предоставление доступа к данным- членам нарушает инкапсуляцию и ограничивает контроль над абстракцией. Как
Перекрестная ссылка Об инкап- суляции см. подраздел «Инкап- сулируйте детали реализации»
раздела 5.3.
Самым важным отличием хоро- шо спроектированного модуля от плохо спроектированного яв- ляется степень, в которой мо- дуль скрывает свои внутренние данные и другие детали реали- зации от других модулей.
Джошуа Блох (Joshua Bloch)
136
ЧАСТЬ II Высококачественный код указывает Артур Риэль, класс
Point (точка), который предоставляет доступ к дан- ным:
float x;
float y;
float z;
нарушает инкапсуляцию, потому что клиентский код может свободно делать с данными
Point что угодно, при этом сам класс может даже не узнать об их изме- нении (Riel, 1996). В то же время класс
Point, включающий члены:
float GetX();
float GetY();
float GetZ();
void SetX( float x );
void SetY( float y );
void SetZ( float z );
поддерживает прекрасную инкапсуляцию. Вы не имеете понятия о том, реализо- ваны ли данные как
float x, y и z, хранит ли класс Point эти элементы как double,
преобразуя их в
float, или же он хранит их на Луне и получает через спутник.
Не включайте в интерфейс класса закрытые детали реализации Истинная инкапсуляция не позволяла бы узнать детали реализации вообще. Они были бы скрыты и в прямом, и в переносном смыслах. Однако популярные языки — в том числе C++ — требуют, чтобы программисты раскрывали детали реализации в интерфейсе класса, например:
Пример обнародования деталей реализации класса (C++)
class Employee {
public:
Employee(
FullName name,
String address,
String workPhone,
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
FullName GetName() const;
String GetAddress() const;
private:
Обнародованные детали реализации.
String m_Name;
String m_Address;
int m_jobClass;
};
>
1 ... 14 15 16 17 18 19 20 21 ... 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)