Руководство по стилю программирования и конструированию по
Скачать 7.6 Mb.
|
ГЛАВА 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) ГЛАВА 6 Классы 141 Базовый класс формулирует ожидания и ограничения, которым должен будет соответствовать производный класс (Meyers, 1998). Если производный класс не собирается полностью придерживаться контракта, определенного интерфейсом базового класса, наследование выполнять не стоит. Попробуйте вместо этого применить включение или внести изменение на более высоком уровне иерархии наследования. Проектируйте и документируйте классы с учетом возможности насле- дования или запретите его Наследование повышает сложность программы, и в этом смысле оно может быть опасным. Поэтому гуру программирования на Java Джошуа Блох и сказал: «Проектируйте и документируйте классы с учетом воз- можности наследования или запретите его». Если при проектировании класса вы решили, что он не должен поддерживать наследование, не объявляйте его члены как virtual в случае C++ или overridable в случае Microsoft Visual Basic; если вы про- граммируете на Java, объявите члены такого класса как final. Соблюдайте принцип подстановки Лисков (Liskov Substitution Principle, LSP) Барбара Лисков как-то заявила, что наследование стоит использовать, только если производный класс действительно «является» более специализированной верси- ей базового класса (Liskov, 1988). Энди Хант и Дэйв Томас сформулировали LSP так: «Клиенты должны иметь возможность использования подклассов через ин- терфейс базового класса, не замечая никаких различий» (Hunt and Thomas, 2000). Иначе говоря, все методы базового класса должны иметь в каждом производном классе то же значение. Если у вас есть базовый класс Account (счет) и производные классы CheckingAccount (счет до востребования), SavingsAccount (депозитный счет) и AutoLoanAccount (счет ссуд), то при вызове каких бы то ни было методов класса Account в любом из его подтипов программист не должен заботиться о подтипе конкретного объекта «счет». При соблюдении принципа подстановки Лисков наследование — мощное сред- ство снижения сложности, позволяющее программисту сосредоточиться на общих атрибутах объекта, не волнуясь об его деталях. Если же программист должен по- стоянно помнить о семантических различиях реализаций подклассов, наследо- вание только повышает сложность. Так, в нашем примере программисту пришлось бы думать: «Если я вызываю метод InterestRate() (процентная ставка) класса Che- ckingAccount или SavingsAccount, он возвращает процент, который банк выплачи- вает клиенту, однако метод InterestRate() класса AutoLoanAccount возвращает про- цент, выплачиваемый клиентом банку, поэтому я должен изменить знак результа- та». В соответствии с LSP, в данном случае класс AutoLoanAccount не должен быть производным от класса Account, потому что методы InterestRate() в этих классах имеют разные семантические значения. Убедитесь, что вы наследуете только то, что хотите наследовать Производный класс может наследовать интерфейсы методов-членов, их реализа- ции или и то, и другое (табл. 6-1). 142 ЧАСТЬ II Высококачественный код Табл. 6-1. Разновидности наследуемых методов Переопределение Переопределение метода возможно метода невозможно Реализация по умолчанию Переопределяемый метод Непереопределяемый метод. имеется Реализация по умолчанию Абстрактный Этот вариант не использует- отсутствует переопределяемый метод ся (нет смысла в том, чтобы оставить метод без определе- ния, не позволив его переоп- ределить). Как следует из таблицы, наследуемые методы могут относиться к одной из трех категорий: 쐽 абстрактный переопределяемый метод: производный класс наследует интер- фейс метода, но не его реализацию; 쐽 переопределяемый метод: производный класс наследует интерфейс метода и его реализацию по умолчанию, а также может переопределить эту реализацию; 쐽 непереопределяемый метод: производный класс наследует интерфейс метода и его реализацию по умолчанию, переопределить которую не может. Создавая новый класс при помощи наследования, обдумайте тип наследования каждого метода-члена. Не наследуйте реализацию только потому, что вы насле- дуете интерфейс, и не наследуйте интерфейс только для того, чтобы унаследовать реализацию. Если вам нужна реализация класса, но не его интерфейс, используй- те включение, а не наследование. Не «переопределяйте» непереопределяемые методы-члены И C++, и Java позволяют программисту переопределить непереопределяемый метод-член — ну, или что-то вроде того. Если функция объявлена в базовом классе как private, в производном классе можно создать функцию с тем же именем. Программист, изучающий код производного класса, может прийти к ложному выводу, что эта функция является полиморфной, хотя на самом деле это не так — просто у нее то же имя. Иначе сформулировать это правило можно так: «Не используйте имена непереопределяемых методов базового класса в производных классах». Перемещайте общие интерфейсы, данные и формы поведения на как мож- но более высокий уровень иерархии наследования Чем ближе интерфейсы, данные и формы поведения к корню дерева наследования, тем легче производ- ным классам их использовать. Какой уровень считать слишком высоким? Руковод- ствуйтесь соображениями абстракции. Если вам кажется, что перемещение ме- тода на более высокий уровень нарушит абстракцию соответствующего класса, не делайте этого. С подозрением относитесь к классам, объекты которых создаются в един- ственном экземпляре Использование единственного экземпляра класса может указывать на то, что вы спутали объекты с классами. Подумайте, можно ли про- сто создать объект вместо нового класса. Можно ли конкретный производный класс представить только данными, а не отдельным классом? Шаблон Одиночка (Sing- leton) — примечательное исключение из этого правила. ГЛАВА 6 Классы 143 С подозрением относитесь к базовым классам, имеющим только один про- изводный класс Когда я вижу базовый класс, имеющий только один производ- ный класс, то начинаю подозревать, что какой-то программист «проектировал на- перед» — пытался предвосхитить будущие потребности, скорее всего не понимая их в полной мере. Лучший способ подготовки к будущей работе — не проектиро- вать дополнительные уровни базовых классов, которые «когда-нибудь могут по- надобиться», а написать максимально ясный, понятный и простой код. Это озна- чает, что иерархию наследования не надо усложнять без крайней нужды. С подозрением относитесь к классам, которые переопределяют метод, оставляя его пустым Как правило, это говорит о неудачном проектировании базового класса. Допустим, вы создали класс Cat, включающий метод Scratch() (царапать), но после обнаружили, что некоторые коты лишены когтей и не могут царапаться. Вы могли бы унаследовать от класса Cat класс ScratchlessCat, переоп- ределив в нем метод Scratch() так, чтобы он ничего не делал. Однако этот подход связан с рядом проблем. 쐽 Он нарушает абстракцию (контракт интерфейса) класса Cat, изменяя семан- тику его интерфейса. 쐽 При расширении на другие производные классы этот подход быстро стано- вится неуправляемым. Что будет, когда вы найдете кота без хвоста? Или кота, который не ловит мышей? Или кота, который не пьет молоко? В итоге у вас могут появиться производные классы вроде ScratchlessTaillessMicelessMilklessCat. 쐽 Код, написанный по этой методике, трудно сопровождать, потому что со вре- менем поведение производных классов начинает сильно отличаться от интер- фейсов и форм поведения базовых классов. Исправлять эту проблему следует не в базовом классе, а в первоначальном классе Cat. Создайте класс Claws (когти) и включите его в класс Cats. Корень наших бед — предположение, что все коты царапаются; предложенный способ позволит устранить причину проблемы, а не бороться с ее следствиями. Избегайте многоуровневых иерархий наследования Объектно-ориентиро- ванное программирование поддерживает массу способов управления сложностью. Но использование любого мощного средства сопряжено с риском, и некоторые объектно-ориентированные подходы часто повышают сложность вместо того, чтобы снижать ее. Артур Риэль в прекрасной книге «Object-Oriented Design Heuristics» (Riel, 1996) предлагает ограничивать иерархии наследования максимум шестью уровнями. Он основывает свой совет на «магическом числе 7±2», но мне кажется, что это слиш- ком оптимистично. Опыт подсказывает мне, что большинству людей трудно удер- жать в уме более двух или трех уровней наследования сразу. «Магическое число 7±2» скорее характеризует максимально допустимое общее количество подклас- сов базового класса, а не уровней иерархии наследования. Создание многоуровневых иерархий наследования значительно повышает число ошибок (Basili, Briand, and Melo, 1996). Тот, кто занимался отладкой сложной иерар- хии наследования, знает причину этого. Многоуровневые иерархии повышают сложность, что диаметрально противоположно цели наследования. Помните про 144 ЧАСТЬ II Высококачественный код Главный Технический Императив и убедитесь, что вы используете наследование, чтобы избежать дублирования кода и минимизировать сложность. Предпочитайте полиморфизм, а не крупномасштабную проверку типов Наличие в коде большого числа блоков case может указывать на то, что програм- му лучше было бы спроектировать, используя наследование, хотя это верно не всегда. Вот классический пример кода, призывающего к использованию более объектно-ориентированного подхода: Пример кода, который следовало бы заменить вызовом полиморфного метода (C++) switch ( shape.type ) { case Shape_Circle: shape.DrawCircle(); break; case Shape_Square: shape.DrawSquare(); break; } Здесь методы shape. DrawCircle() и shape. DrawSquare() следует заменить на един- ственный метод shape. Draw(), поддерживающий рисование и окружностей, и прямоугольников. С другой стороны, иногда блоки case служат для разделения по-настоящему раз- ных видов объектов или форм поведения. Так, следующий фрагмент вполне уме- стен в объектно-ориентированной программе: Пример кода, который, пожалуй, не следует заменять вызовом полиморфного метода (C++) switch ( ui.Command() ) { case Command_OpenFile: OpenFile(); break; case Command_Print: Print(); break; case Command_Save: Save(); break; case Command_Exit: ShutDown(); break; } В данном случае можно было бы создать базовый класс и унаследовать от него ряд производных классов, выполняющих каждую команду при помощи полиморфно- го метода DoCommand() (как в шаблоне Команда). Но в подобной простой ситуа- |