Файл: Руководство по стилю программирования и конструированию по.pdf
ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 30.11.2023
Просмотров: 751
Скачиваний: 2
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
ГЛАВА 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() (как в шаблоне Команда). Но в подобной простой ситуа-
1 ... 15 16 17 18 19 20 21 22 ... 104
ГЛАВА 6 Классы
145
ции это неуместно: имя метода
DoCommand() было бы настолько туманным, что почти утратило бы всякий смысл, тогда как блоки
case довольно информативны.
Делайте все данные закрытыми, а не защищенными Как говорит Джошуа
Блох, «наследование нарушает инкапсуляцию» (Bloch, 2001). Выполняя наследо- вание от класса, вы получаете привилегированный доступ к его защищенным методам и данным. Если производному классу на самом деле нужен доступ к ат- рибутам базового класса, включите в базовый класс защищенные методы доступа.
Множественное наследование
Наследование — мощный и... довольно опасный инструмент.
В некотором смысле наследование похоже на цепную пилу:
при соблюдении мер предосторожности оно может быть невероятно полезным, но при неумелом обращении послед- ствия могут оказаться очень и очень серьезными.
Если наследование — цепная пила, то множественное на- следование — это старинная цепная пила с барахлящим мотором, не имеющая предохранителей и не поддержива- ющая автоматического отключения. Иногда такой инструмент может пригодить- ся, но большую часть времени его лучше хранить в гараже под замком.
Некоторые эксперты рекомендуют широкое применение множественного насле- дования (Meyer, 1997), но по опыту могу сказать, что оно полезно главным обра- зом только при создании «миксинов» — простых классов, позволяющих добавить ряд свойств в другой класс. Миксины называются так потому, что они позволяют
«подмешать (mix in)» свойства в производные классы. Миксинами могут быть классы вроде
Displayable, Persistent, Serializable или Sortable. Миксины почти всегда явля- ются абстрактными и не поддерживают создания экземпляров независимо от других объектов.
Миксины требуют множественного наследования, но пока все миксины по-насто- ящему независимы друг от друга, вы можете не бояться классической проблемы,
связанной с ромбовидной схемой наследования. Кроме того, «объединяя» атри- буты, они делают проект системы понятнее. Программисту легче разобраться с объектом, использующим миксины
Displayable и Persistent, а не 11 более конкрет- ных методов, которые понадобились бы для реализации этих двух свойств в про- тивном случае.
Похоже, разработчики Java и Visual Basic понимали ценность миксинов, разрешив множественное наследование интерфейсов, но только единичное наследование классов. C++ поддерживает множественное наследование и интерфейсов, и реа- лизации. Используйте множественное наследование, только тщательно рассмот- рев все альтернативные варианты и проанализировав влияние выбранного под- хода на сложность и понятность системы.