Объектно-ориентированный подход. Объектно_ориентированный_подход. Объектно ориентированный подход Мэтт Вайсфельд 5е международное издание ббк 32. 973. 2018
Скачать 5.43 Mb.
|
НЕДОСТАТКИ MVC ____________________________________________________________________ Несмотря.на.то.что.принцип.MVC.великолепен,.могут.возникнуть.трудности,.по- скольку.много.внимания.требуется.уделить.прозрачности.исполнения..Это.общая. задача.для.всего.объектно-ориентированного.проектирования.—.существует.зыбкая. грань.между.аккуратным.и.громоздким.исполнением..Остается.вопрос:.с.учетом. оценки.всего.исполнения,.насколько.сложной.будет.созданная.система? Типы паттернов проектирования Существует 23 паттерна, разбитых по группам на три категории, которые при- ведены ниже. Большинство примеров написано на C++, а некоторые — на Smalltalk. Для времени первого издания книги характерно применение C++ Глава.10..Паттерны.проектирования 210 и Smalltalk. В 1995 году мир как раз находился на пороге интернет-революции и, соответственно, популярности языка программирования Java. После того как преимущества паттернов проектирования стали очевидными, множество других книг вышло покорять появившийся рынок. В любом случае на самом деле неважно, какой язык используется. «Приемы объектно-ориентированного проектирования. Паттерны проектирования» пред- ставляет собой руководство по проектированию, а сами паттерны находят при- менение в бесчисленном количестве языков программирования. Авторы книги разделили паттерны на три категории. Порождающие паттерны позволяют создавать объекты, благодаря этому их не приходится создавать непосредственно разработчику. Применение пат- тернов обеспечивает большую гибкость программы при выборе объектов, которые требуется создать в зависимости от случая. Структурные паттерны позволяют объединять группы объектов в более сложные конструкции, например сложные пользовательские интерфейсы или учетные данные. Поведенческие паттерны позволяют задать способы взаимодействия объ- ектов в системе и управления потоком в сложной программе. Чтобы дать понимание того, что представляют собой паттерны проектирования, в следующих разделах приводится разбор одного примера для каждой из трех категорий. В источниках, перечисленных в конце этой главы, можно найти полный список и описание отдельных паттернов проектирования. Порождающие паттерны Есть несколько категорий порождающих паттернов: абстрактная фабрика; строитель; фабричный метод; прототип; одиночка. Как говорилось ранее, задача этой главы — дать объяснение, что такое паттерн проектирования, а не подробный разбор каждого паттерна из книги Банды че- тырех. Поэтому мы рассмотрим по одному паттерну из каждой категории. С учетом вышесказанного рассмотрим пример порождающего паттерна — «фа- бричный метод». 211 Типы.паттернов.проектирования.. . Фабричный метод Создание или обработка объектов по праву может считаться одной из концепций, на которых зиждется объектно-ориентированное программирование. Само со- бой разумеется, что невозможно использовать объект, покуда он не существует. При написании кода наиболее очевидный способ при создании объекта — ис- пользовать ключевое слово new Чтобы проиллюстрировать это, давайте обратимся к примеру с фигурами, ко- торые уже встречались в этой книге. Вот уже знакомый нам родительский класс Shape , который абстрактен, и дочерний класс Circle , который представляет собой конкретную реализацию. Мы создаем экземпляр класса Circle обычным способом посредством ключевого слова new : abstract class Shape { } class Circle extends Shape { } Circle circle = new Circle(); Хотя такой код обязательно будет работать, в нем могут находиться также мно- гие другие места, где нужно создать экземпляр класса Circle , либо, в зависимо- сти от случая, класса другой фигуры. Во многих случаях при создании объекта будут требоваться определенные параметры, которые придется задавать каждый раз при создании класса Shape В результате каждый раз при смене способа создания объектов требуется вносить изменения в код в каждом месте, где нужно создать объект Shape . Такой код в высокой степени связан, поскольку изменение в одном месте потенциально требует изменений также во многих других местах. Еще одна проблема этого подхода заключается в том, что он предоставляет логику создания объектов программистам при помощи классов. Исправить ситуацию позволит применение паттерна «фабричный метод». В двух словах, «фабричный метод» отвечает за инкапсуляцию всех экземпляров, обе- спечение единообразия во всей реализации. Вы используете фабрику для создания экземпляра, а фабрика отвечает за соз- дание экземпляра должным образом. Паттерн «фабричный метод» Основное назначение паттерна «фабричный метод» — создание объекта без не- обходимости точного указания класса, то есть использование интерфейсов для создания новых типов объектов. Глава.10..Паттерны.проектирования 212 Чтобы дать представление о реализации этого паттерна, в качестве примера создадим «фабрику» для класса Shape . На рис. 10.2 изображена диаграмма клас- сов, которая наглядно раскрывает, как в этом примере взаимодействуют раз- личные классы. Shape ShapeFactory generateShape Shape() generate() Square Square() Circle() Circle generate() generate() generate() Triangle Triangle() enum ShapeType Extends Extends Extends Рис. 10.2..Создание.«фабрики».для.класса.Shape. В некотором роде можно говорить о «фабрике» как об обертке. Стоит учитывать то, что при создании объекта может быть задействована какая-то важная логи- ка, поэтому не нужно, чтобы программист (пользователь) мог видеть эту логи- ку, — почти как в концепции метода мутаторов (геттеры и сеттеры), когда возврат значения находится внутри какой-либо логики (вроде случаев, когда требуется ввести пароль). Использование «фабричного метода» полезно, когда заведомо неизвестно, какой класс может понадобиться. К примеру, известно, что нужна будет фигура, но вот какая именно — никто не имеет понятия (по крайней мере, пока). Поэтому нужно не забывать, что все возможные классы должны нахо- диться в одной иерархии, то есть все классы в этом примере будут подклассами класса Shape . Собственно, «фабрика» нужна именно тогда, когда точно неиз- вестно, что именно нужно, благодаря ей можно добавлять некоторые классы впоследствии. Если бы было известно, что нужно конкретно, можно было бы просто вставить необходимый экземпляр с помощью конструктора или сеттера. В своей основе это вытекает из определения полиморфизма. Мы создаем перечисляемый тип с помощью кодового слова enum , который будет содержать разные типы фигур. В этом примере будут фигуры CIRCLE (круг), SQUARE (квадрат) и TRIANGLE (треугольник). 213 Типы.паттернов.проектирования.. . enum ShapeType { CIRCLE, SQUARE, TRIANGLE } Абстрактный класс Shape задается только с помощью конструктора и абстракт- ного метода generate () abstract class Shape { private ShapeType sType = null; public Shape(ShapeType sType { this.sType = sType; } // Создает защищенную форму абстрактного void generate (); } Дочерние классы CIRCLE , SQUARE и TRIANGLE расширяют класс Shape , идентифи- цируют себя и обеспечивают конкретную реализацию метода generate() class Circle extends Shape { Circle() { super (ShapeType.CIRCLE); generate(); } @Override protected void generate() { System.out.printin("Generating a Circle"); } } class Square extends Shape { Square() { super (ShapeType. SQUARE); generate(); } @Override protected void generate() { System.out.printin("Generating a Square") ; } } class Triangle extends Shape { Triangle() { super (ShapeType. TRIANGLE) ; generate(); Глава.10..Паттерны.проектирования 214 } @Override protected void generate() { System.out.printin("Generating a Triangle"); } } Класс ShapeFactory , как видно из названия, на самом деле является «фабрикой». Сосредоточимся на методе generate () . При том, что «фабрика» имеет много преимуществ, не стоит забывать, что метод generate() — это единственное место в приложении, которое действительно создает экземпляр класса Shape class ShapeFactory { public static Shape generateShape(ShapeType sType) { Shape shape = null; switch (sType) { case CIRCLE: shape = new Circle(); break; case SQUARE: shape = new Square(); break; case TRIANGLE: shape = new Triangle(); break; default: // throw an exception break; } return shape; } } При традиционном подходе для создания каждого отдельного объекта програм- мист будет вынужден собственноручно создавать объекты с помощью ключе- вого слова new , как указано ниже: public class TestFactoryPattern { public static void main(String[] args) { Circle circle = new Circle(); Square square = new Square(); Triangle triangle = new Triangle(); } } 215 Типы.паттернов.проектирования.. . Чтобы правильно использовать «фабрику», программист должен применять класс ShapeFactory для получения какого-либо из объектов Shape : public class TestFactoryPattern { public static void main(String[] args) { ShapeFactory.generateShape(ShapeType.CIRCLE) ; ShapeFactory.generateShape (ShapeType. SQUARE) ; ShapeFactory.generateShape(ShapeType. TRIANGLE) ; } } Структурные паттерны Структурные паттерны используются для создания сложных структур из групп объектов. Следующие семь паттернов проектирования принадлежат к категории структурных: адаптер; мост; компоновщик; декоратор; фасад; приспособленец; заместитель. В качестве примера структурного паттерна рассмотрим «адаптер». Паттерн «адаптер» также является одним из важнейших паттернов проектирования. Этот паттерн дает хорошее представление о том, как реализация отделена от интер- фейса. Паттерн «адаптер» Паттерн «адаптер» дает возможность создать другой интерфейс для уже суще- ствующего класса. Сам по себе паттерн «адаптер» предоставляет обертку для класса. Другими словами, создается новый класс, который включает в себя (обертывает) функциональность уже существующего класса посредством ново- го, а по возможности — лучшего интерфейса. Простой пример обертки — это класс Integer в языке Java. Класс Integer обертывает собой одно целочисленное значение. Возникает вопрос, зачем вообще это надо. Дело в том, что в объектно- ориентированной системе все является объектом. В языке Java примитивные типы наподобие int , float и т. д. не являются объектами. Когда необходимо вы- полнить какие-либо действия над такими типами, например преобразования, требуется представить их как объекты. Таким образом, для этого создается объ- Глава.10..Паттерны.проектирования 216 ект-обертка, который содержит в себе примитивный тип. Можно взять такое значение примитивного типа: int myInt = 10; и обернуть его в объект Integer : Integer myIntWrapper = new Integer (myInt); Теперь можно выполнить преобразование типа посредством следующей строки: String myString = myIntWrapper.toString(); Такая обертка дает возможность представлять исходное целое число как объект, предоставляя все преимущества объекта. Что касается самого паттерна «адаптер», рассмотрим пример интерфейса электронной почты. Предположим, что вы приобрели какой-либо код, который предоставляет функциональность, которая необходима для обеспечения работы почтового клиента. Этот инструмент предоставляет все необходимое для по- чтового клиента, за исключением случаев, когда нужно внести в интерфейс небольшие правки. Фактически все, что нужно сделать, — это изменить интер- фейс прикладного программирования (API) для получения почты. Ниже приведен класс, который является простым примером почтового при- мера в данном контексте: package MailTool; public class MailTool { public MailTool () { } public int retrieveMail() { System.out.printin ("You've Got Mail"); return 0; } } Когда происходит вызов метода r etrieveMail() , получение нового письма со- провождается уведомлением «You’ve Got Mail» (у вас новое письмо). Теперь предположим, что нужно изменить интерфейс во всех клиентах компании с retrieveMail() на getMail() . Можно создать интерфейс для обеспечения этого: package MailTool; interface MailInterface { int getMail(); } 217 Типы.паттернов.проектирования.. . Теперь можно создать свою почтовую службу, которая обертывает собой ту, что была изначально, и предоставляет необходимый интерфейс: package MailTool; class MyMailTool implements MailInterface { private MailTool yourMailTool; public MyMailTool () { yourMailTool= new MailTool(); setYourMailTool (yourMailTool) ; } public int getMail() { return getYourMailTool().retrieveMail(); } public MailTool getYourMailTool() { return yourMailTool ; } public void setYourMailTool(MailTool newYourMailTool) { yourMailTool = newYourMailTool ; } } Внутри данного класса создается экземпляр исходной почтовой службы, кото- рую необходимо усовершенствовать. Этот класс реализует интерфейс , который принудительно задействует метод getMail() . Внутри этого метода буквально происходит вызов метода retrieveMail() той почтовой службы, которая была изначально. Чтобы задействовать новый класс, требуется создать экземпляр новой почтовой службы и вызвать метод getMail() package MailTool; public class Adapter { public static void main(String[] args) { MyMailTool myMailTool = new MyMailTool(); myMailTool.getMail(); } } Когда происходит вызов метода getMail() , с помощью такого нового интерфей- са происходит вызов метода retrieveMail() из первоначальной почтовой служ- бы. Это очень простой пример. И все же благодаря созданию обертки можно улучшить интерфейс и добавить новый функционал к уже существующему классу. Несмотря на то что концепция паттерна «адаптер» довольно проста, с помощью него можно создавать новые интерфейсы с широкими возможностями. Глава.10..Паттерны.проектирования 218 Паттерны поведения Паттерны поведения, или поведенческие паттерны, поделены на следующие категории: цепочка обязанностей; команда; интерпретатор; итератор; посредник; хранитель; наблюдатель; состояние; стратегия; шаблонный метод; посетитель. В качестве примера поведенческого паттерна рассмотрим «итератор». Это один из наиболее часто используемых паттернов, реализованный не в одном языке программирования. Паттерн «итератор» Итераторы предоставляют стандартный механизм последовательного обраще- ния к коллекции, например к вектору. Функциональность может быть обеспе- чена так, чтобы к каждому элементу коллекции можно было получить доступ последовательно. Паттерн «итератор» предоставляет сокрытие данных, под- держивая таким образом безопасность внутренней структуры коллекции. Пат- терн «итератор» также предусматривает возможность создания более одного итератора, каждый из которых будет функционировать без помех для остальных. В Java применяется собственная реализация итератора. Приведенный ниже код создает вектор, затем задает количество строк в нем: package Iterator; import java.util.*; public class Iterator { public static void main(String args[]) { // Instantiate an ArrayList. ArrayList // Add values to the ArrayList 219 Антипаттерны. . names.add(new String("Joe")) ; names.add(new String("Mary")); names.add(new String("Bob")) ; names.add(new String("Sue")) ; //Now Iterate through the names System.out.printin("Names:") ; iterate(names) ; } private static void iterate(ArrayList for(String listItem : arl) { System.out.printin(listItem.toString()); } } } Затем мы создаем такое перечисление, чтобы можно было выполнить его итерацию. Метод iterate() предназначен для выполнения итерации. В данном методе применяется метод перечисления Java hasMoreElements() , который обес печивает последовательный доступ к вектору и выводит перечень всех названий. Антипаттерны Несмотря на то что паттерны проектирования развиваются из положительного опыта, существуют также антипаттерны, которые отражают неудачный опыт. Существует достаточно документальных свидетельств о том, что большинство проектов в области разработки программного обеспечения в конце концов за- вершается провалом. Как отмечено в статье «Создание хаоса» Джонни Джон- сона («Creating Chaos», Johnny Johnson), добрая треть всех проектов полностью прекращается. Очевидно, что многие из этих провалов произошли из-за плохих проектных решений Термин антипаттерн происходит из того, что паттерны проектирования создаются для решения определенного типа задач в рамках некоторого часто возникающего контекста. Антипаттерн же является реакцией на задачу и по- является благодаря неудачному опыту. В то время как паттерны проектиро- вания полагаются на принципы SOLID и успешные случаи применения, анти- паттерны можно считать конструкциями, которые нужно избегать. В докладе по C++ в ноябре 1995 года Эндрю Кениг (Andrew Koenig) назвал два основных признака антипаттернов: Они предлагают неправильное решение задачи, которое ведет к неудаче. В то же время они предлагают выход из положения и способы перехода от неудачного решения к успешному. Глава.10..Паттерны.проектирования 220 Многие разработчики уверены, что антипаттерны полезнее самих паттернов проектирования. Так происходит из-за того, что антипаттерны разработаны для решения проблем, которые уже появились. Это сводится к концепции анализа первопричины. Можно провести исследования с данными, которые могут ука- зывать, почему исходное исполнение, возможно, являющееся паттерном про- ектирования, оказалось неудачным. Можно сказать, что антипаттерны возни- кают из провала предыдущих решений. Поэтому антипаттерны позволяют рассмотреть проблему в ретроспективе. Например, в статье «Повторное использование паттернов и антипаттернов» Скот Эмблер описал паттерн, названный «надежный артефакт», и дал ему такое определение: Конструкция с хорошей документацией, созданная для удовлетворения общих потребностей, а не для конкретных потребностей проекта, тща- тельно протестированная и снабженная несколькими примерами, чтобы показать, как с ней работать. Код с такими качествами гораздо более при- способлен к повторному использованию, чем код без таких качеств. «На- дежный артефакт» — это конструкция, которую легко понять и с которой легко работать. Однако, конечно, происходит много случаев, когда заявлено, что решение при- годно для повторного использования, но никто никогда не использует его по- вторно. Таким образом, чтобы дать понимание антипаттерна, он пишет: Кто-то помимо изначального разработчика должен пересмотреть «непри- годный артефакт», чтобы определить, может ли кто-нибудь проявить к нему интерес. Если да, то такой артефакт нужно переделать в «надеж- ный». Таким образом, антипаттерны позволяют пересматривать существующие кон- струкции проектирования и проводить непрерывную реорганизацию кода до тех пор, пока не будет найдено работающее решение. НЕКОТОРЫЕ УМЕСТНЫЕ ПРИМЕРЫ АНТИПАТТЕРНОВ ________________________________ y Одиночка. y Локатор.служб. y Магические.числа/строки. y Раздувание.интерфейса. y Кодирование.путем.исключения. y Сокрытие/проглатывание.ошибок. 221 Ссылки. . Заключение В этой главе мы рассмотрели концепцию паттернов проектирования. Паттер- ны — это часть повседневной жизни разработчика, а также способ мышления в объектно-ориентированной разработке. Как и многое, что относится к инфор- мационным технологиям, корни решений создаются в окружающей действитель- ности. Хотя в этой главе дано только краткое описание паттернов проектирования, настоятельно советуем углубиться в эту тему, ознакомившись с какой-нибудь из соответствующих книг. Ссылки Александер Кристофер с соавт. «Язык шаблонов. Города. Здания. Строитель- ство» (A Pattern Language: Towns, Buildings, Construction). Кембридж, Велико- британия: Издательство Оксфордского Университета, 1977. Эмблер Скотт. «Повторное использование паттернов и антипаттернов» (Reuse Patterns and Antipatterns) // Журнал разработчика программного обеспечения, 2000. Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентирован- ного проектирования. Паттерны проектирования. — СПб.: Питер, 2020. — 368 с.: ил. (Серия «Библиотека программиста»). Грэнд Марк. «Паттерны в Java: Каталог повторно используемых паттернов про- ектирования с пояснениями в UML-схемах» (Patterns in Java: A Catalog of Reusable Design Patterns Illustrated with UML). — 2-е изд., том 1. Хобокен, Нью- Джерси: Вайли, 2002. Яворски Джейми. «Реализация платформы Java 2» (Java 2 Platform Unleashed). Индианаполис, Индиана: Sams Publishing, 1999. Джонсон Джонни. «Создание Хаоса» (Creating Chaos) // Американский про- граммист, 1995 год, июль. Ларман, Крэг. «Применение UML и Паттернов: Введение в объектно-ориенти- рованный анализ, проектирование и итеративная разработка» (Applying UML and Patterns: An Introduction to Object-Oriented Analysis and Design and Iterative Development), 3-е изд. Хобокен, Нью-Джерси: Вайли, 2004. Глава 11 ИЗБЕГАНИЕ ЗАВИСИМОСТЕЙ И ТЕСНО СВЯЗАННЫХ КЛАССОВ Как уже говорилось в главе 1 «Введение в объектно-ориентированные концеп- ции», традиционные критерии классического объектно-ориентированного про- граммирования — это инкапсуляция, наследование и полиморфизм. В теории, чтобы рассматривать язык программирования как объектно-ориентированный, он должен соответствовать этим трем принципам. Кроме того, как уже отмеча- лось в главе 1, лично я предпочитаю дополнить список композицией. Таким образом, когда я провожу обучение объектно-ориентированному про- граммированию, мой список фундаментальных концепций выглядит так: Инкапсуляция. Наследование. Полиморфизм. Композиция. ПОДСКАЗКА __________________________________________________________________________ Возможно,.в.этом.списке.не.хватает.интерфейсов,.но.я.уже.принимаю.интерфейсы. за.особый.тип.наследования. Дополнение этого списка композицией даже более важно в среде разработчиков на сегодняшний день, поскольку не прекращается спор о том, как правильно использовать наследование. Проблема использования интерфейсов достаточно давняя. В последние несколько лет споры только усилились. Многие разработчики, с которыми я беседовал, ратуют за использование ком- позиции вместо наследования (что часто называют «композиция против на- следования»). На самом деле, некоторые разработчики полностью избегают наследования или, по крайней мере, ограничивают использование наследования единственным уровнем иерархии. 223 Избегание.зависимостей.и.тесно.связанных.классов. . Причина столь большого внимания к использованию наследования происходит от проблемы связывания. В пользу использования наследования говорит, безус- ловно, возможность повторного использования, расширяемость и полиморфизм, в то же время наследование может привести к проблемам возникновения за- висимостей между классами, а именно их связыванию. Такие зависимости сулят проблемы при сопровождении и тестировании. В главе 7 «Наследование и композиция» обсуждалось, как наследование может на самом деле ослабить инкапсуляцию, что звучит нелогично, поскольку и то и другое является фундаментальной концепцией. Тем не менее это лишь часть всего веселья, которая заключается в том, что действительно нужно думать, как правильно применить наследование. ПРЕДОСТЕРЕЖЕНИЕ __________________________________________________________________ Имейте.в.виду,.что.я.не.сторонник.избегания.наследования..Речь.прежде.всего.идет. о.том,.что.нужно.избегать.зависимостей.и.тесно.связанных.классов..Когда.именно. имеет.смысл.использовать.наследование.—.важный.предмет.дискуссий. Такие разговоры ведут к тому, что встает вопрос: если не наследование, то что тогда? Можно использовать композицию. Вряд ли это кого-то удивит, потому что по ходу книги я не прекращаю утверждать, что в реальности есть только два способа повторного использования классов: использование наследования и ис- пользование композиции. Можно создать дочерний класс с помощью наследо- вания от родительского либо можно включить один класс в состав другого по- средством композиции. Если, как утверждают многие, наследования необходимо избегать, зачем мы тратим на него время, изучая? Ответ прост: много кода написано с использова- нием наследования. Как вскоре понимает большинство разработчиков, преоб- ладающее количество кода встречается именно при сопровождении. Таким образом, необходимо понимание того, как исправить, улучшить и правильно сопровождать код, написанный с использованием наследования. Может даже быть такое, что вы сами напишете новый код с наследованием. Проще говоря, программисту нужно охватить все возможные основы и полностью изучить инструментарий разработчика. Однако это также означает, что придется рас- ширять набор используемых инструментов, а еще то, что нужно переосмыслить, как мы их будем использовать. И повторюсь, поймите, пожалуйста, что я не хотел выносить никаких оценочных суждений. Я не могу утверждать, что от наследования одни проблемы и лучше совсем без него. Я хочу сказать о том, что важно в полной мере понять, как ис- пользуется наследование, вдумчиво изучить альтернативные способы исполне- ния, а затем уже самим для себя решить. Следовательно, цель примеров в этой главе не в том, чтобы предложить оптимальный способ проектирования классов. Это лишь примеры с целью дать представление о проблемах, связанных с вы- Глава.11..Избегание.зависимостей.и.тесно.связанных.классов 224 бором между наследованием и композицией. Стоит помнить, что для всех тех- нологий важно развитие: сохранение хорошего и отсеивание того, что не очень. Более того, у композиции есть свои проблемы связывания классов. В главе 7 я говорил о различных типах композиций: ассоциации и агрегации. Агрегации представляют собой объекты, встроенные в другие объекты (созданные с по- мощью ключевого слова new ), в то время как ассоциации являются объектами, которые попадают внутрь других объектов через список параметров. Посколь- ку агрегации встроены в объекты, они тесно связаны, а этого мы хотим избежать. Поэтому в то время как наследование получило в глазах многих репутацию причины тесного связывания классов, композиция (при использовании агрега- ций) также может привести к тесному связыванию. Вернемся к примеру с ком- понентами стереосистемы, который приводился в главе 9 «Создание объектов и объектно-ориентированное проектирование» затем, чтобы свести воедино эти концепции в конкретный пример. Создание стереосистемы с помощью агрегации приведет к созданию магнитолы, компоненты которой интегрированы в единую систему. Это может быть удобно во многих ситуациях. Ее можно взять с собой, легко переносить, а еще не надо ничего подключать. Однако такое исполнение может принести много проблем. Если один компонент, скажем MP3-плеер, выйдет из строя, то в ремонт при- дется нести все устройство целиком. Что еще хуже, вся система может разом выйти из строя и стать непригодной к дальнейшему использованию, например, из-за скачка напряжения. Создание стереосистемы методом ассоциации может помочь снизить риск про- блем, которые возникнут при агрегации. Можно рассмотреть стереосистему, состоящую из многих компонентов, как связку ассоциаций, объединенных про- водами или беспроводной связью. При таком исполнении присутствует цен- тральный объект, называемый приемником, который подключен к некоторым другим объектам, например динамикам, CD-плеерам, даже, допустим, к магни- тофонам или граммофонам. На самом деле это можно рассмотреть как решение, не зависящее от выбора поставщика, поскольку у нас есть основное преимуще- ство — использовать тот компонент, который нас устраивает. В таком случае, если выйдет из строя CD-плеер, его можно будет просто от- ключить, при этом будет оставаться возможность либо его починить (используя систему временно без него), либо просто заменить на новый рабочий. В этом заключается преимущество ассоциаций — сведения связывания классов к ми- нимуму. ПОДСКАЗКА __________________________________________________________________________ Как.уже.было.замечено.в.главе.9,.хотя,.как.правило,.тесно.связанные.классы.не. одобряются,.бывают.случаи,.когда.имеет.смысл.смириться.с.риском.тесного.свя- зывания..Магнитола.—.это.лишь.один.из.примеров..Несмотря.на.то.что.у.нее.тесно. связанное.исполнение,.она.иногда.может.быть.предпочтительнее. 225 Композиция.против.наследования.и.внедрения.зависимостей. . Теперь, когда мы рассмотрели проблемы тесного связывания классов как при наследовании, так и при композиции, рассмотрим примеры тесно связанных конструкций с применением обоих методов. Подобно тому, как я веду лекции, мы будем неоднократно повторять эти примеры, пока не дойдем до техники, называемой «внедрение зависимостей», для смягчения последствий проблем связывания. Композиция против наследования и внедрения зависимостей Для начала можно сосредоточиться на том, как взять модель (из примеров, которые часто приводились в этой книге), построенную с помощью наследова- ния, и перепроектировать ее уже с помощью композиции. Второй пример по- казывает, как можно перепроектировать класс с помощью композиции, пусть даже используя агрегацию, хотя не всегда это оптимальное решение. Третий пример показывает, как избегать агрегаций и применять ассоциации вместо них, и дает представление о концепции внедрения зависимостей. 1. Наследование Независимо от того, интересует ли вас спор о том, чему отдавать свое предпо- чтение, начнем с простого примера наследования и рассмотрим способы, как можно было выполнить конструкцию с применением композиции. Для этого вернемся к примеру с классом Mammal , который неоднократно уже встречался на страницах этой книги. В этом случае введем класс Bat (летучая мышь) — млекопитающее, но умеющее летать, как можно увидеть на рис. 11.1. Рис. 11.1..Использование.наследования.для.создания.. классов.млекопитающих Глава.11..Избегание.зависимостей.и.тесно.связанных.классов 226 В частности, в этом примере выбор наследования является очевидным. Класс Dog , который мы создаем, наследует от класса Mamma l, верно? Посмотрите на код ниже, который использует наследование вот так: class Mammal { public void eat () {System.out.printin("I am Eating") ;}; } class Bat extends Mammal { public void fly () {System.out.printin("I am Flying") ;}; } class Dog extends Mammal { public void walk () {System.out.printIn("I am Walking") ;}; } public class TestMammal { public static void main(String args[]) { System.out.printIn("Composition over Inheritance") ; ; System.out.print1n("\nDog") ; Dog fido = new Dog(); fido.eat(); fido.walk(); System.out.printIn("\nBat") ; Bat brown = new Bat(); brown.eat(); brown. fly(); } } В этой конструкции класс Mammal имеет одно поведение, eat() , предполагая, что все млекопитающие принимают пищу. Однако мы заметим проблему наследо- вания сразу же, как добавим два подкласса Mammal — Bat и Dog . Собака-то может ходить, а вот могут ли все другие млекопитающие? К тому же летучая мышь может передвигаться в воздухе, хотя далеко не все остальные млекопитающие могут. Вопрос состоит в том, как работают эти методы. По аналогии с приведен- ным ранее примером с пингвином (напомним, что не все птицы умеют летать), принятие решения о местоположении того или иного метода в иерархии на- следования подчас вызывает затруднение. Разделение класса Mammal на FlyingMammals и WalkingMammals — не особо изящ- ное решение, поскольку это только верхушка пресловутого айсберга. Есть мле- копитающие, которые плавают, есть даже те, которые откладывают яйца. Кроме того, явно можно понять, что существует бесчисленное множество других черт поведения, присущих тем или иным видам. Непрактично создавать отдельный 227 Композиция.против.наследования.и.внедрения.зависимостей. . класс для каждого возможного поведения. Получается, вместо отношения «яв- ляется экземпляром» в такой конструкции, пожалуй, лучше применить отно- шение «содержит как часть». 2. Композиция При такой стратегии вместо непосредственного встраивания поведений в клас- сы мы создаем отдельные классы для каждого поведения. Потому, вместо того чтобы помещать поведения в иерархию наследования, можно создать классы для каждого поведения и уже потом создавать модели разных млекопитающих, задействовав только те поведения, которые необходимы (с помощью агрегации). Таким образом, мы создаем класс Walkable и класс Flyable , как показано на рис. 11.2. TestMammal Dog Bat Walkable walk() Mammal eat() Flyable fly() Рис. 11.2..Использование.композиции.для.создания.классов.млекопитающих. Например, взгляните на следующий код. У нас по-прежнему есть класс Mammal с методом eat() , а еще есть классы Dog и Bat . Главное различие при проектиро- вании в том, что поведения классов Dog и Bat задаются с помощью композиции, а именно агрегации. ПРЕДОСТЕРЕЖЕНИЕ __________________________________________________________________ Обратите.внимание.на.то,.что.мы.недавно.уже.упоминали.агрегацию..Этот.пример. объясняет.нам,.как.композиция.используется.вместо.наследования..Опять.же,.в.этом. примере.применяется.агрегация.со.значительной.степенью.связывания.классов.. Соответственно,.рассмотрим.его.как.промежуточный.общеразвивающий.этап,.ве- дущий.к.следующему.примеру,.в.котором.будут.применяться.интерфейсы. Глава.11..Избегание.зависимостей.и.тесно.связанных.классов 228 class Mammal { public void eat () {System.out.printin("I am Eating") ;}; } class Walkable { public void walk () {System.out.printin("I am Walking") ;}; } class Flyable { public void fly () {System.out.printIn("I am Flying") ;}; } class Dog { Mammal dog = new Mammal () ; Walkable walker = new Walkable(); } class Bat { Mammal bat = new Mammal () ; Flyable flyer = new Flyable(); } public class TestMammal { public static void main(String args[]) { System.out.printIn("Composition over Inheritance") ;; System.out.printin("\nDog") ; ; Dog fido = new Dog(); fido.dog.eat(); fido.walker.walk(); System.out.printin("\nBat") ; ; Bat brown = new Bat(); brown.bat.eat(); brown.flyer.fly(); } } ПРИМЕЧАНИЕ _________________________________________________________________________ Целью.этого.примера.является.наглядно.объяснить,.как.применять.композицию. вместо.наследования..Но.это.совсем.не.значит,.что.нигде.в.проектировании.не.нуж- но.применять.наследование..Если.вы.полагаете,.что.все.млекопитающие.едят,.то,. например,.можно.решить.поместить.метод. eat () .в.класс. Mammal и.передать.этот. метод.с.помощью.наследования.классам. Dog .и. Bat ..Как.правило,.это.решение.за- висит.от.выбранного.исполнения. Есть вероятность, что в основе споров лежит концепция, о которой говорилось ранее, что наследование нарушает инкапсуляцию. Это нетрудно понять, потому что изменение в классе Mammal будет требовать перекомпиляции (возможно, даже повторного внедрения) всех подклассов Mammal . Это означает, что классы связаны тесно, а это противоречит поставленной цели — обеспечить как можно меньшее связывание классов. 229 Композиция.против.наследования.и.внедрения.зависимостей. . В нашем примере с композицией, если бы мы захотели добавить класс Whale (кит), не пришлось бы переписывать никакие из уже созданных классов. Тогда бы мы просто добавили класс Swimmab1e и класс Whale Класс Swimmab1e можно повторно использовать, скажем, для класса Dolphin (дельфин). class Swimmable { public void fly () {System.out.printiIn("I am Swimming") ;}; } class Whale { Mammal whale = new Mammal(); Walkable swimmer = new Swimmable (); } Добавление функционала с помощью основного приложения может произойти без изменений уже существующих классов. System.out.printIn("\nWhale"); Whale shamu = new Whale(); shamu.whale.eat(); shamu.swimmer.swim(); Одно из железных правил — использовать наследование только в случаях, когда по-настоящему присутствует полиморфизм. Например, при создании классов Circle и Rectangle , наследующих от класса Shape , вполне разумно использовать наследование. С другой стороны, поведения, такие как умение ходить и летать, вряд ли хорошо пригодны для применения наследования, поскольку их переопре- деление может принести проблемы. Например, если нужно скорректировать метод f1y() для класса Dog , единственным очевидным способом будет команда (no-op) — не выполнять никаких действий. Опять же, как и в примере с классом Penguin , нам не нужно, чтобы класс Dog мог летать по воздуху и выполнялся при- сутствующий метод fly() . Поэтому, к большому разочарованию нашего Тузика, метод fly() не приведет ни к каким реальным действиям. Хотя в этом примере действительно применена композиция, такое исполнение имеет важный изъян. Объекты тесно связаны, поскольку мы видим использо- вание ключевого слова new class Whale { Mammal whale = new Mammal () ; Walkable swimmer = new Swimmable (); } В завершение нашего упражнение по снижению связанности классов введем концепцию внедрения зависимостей. Простым языком, вместо того чтобы соз- давать объекты внутри других, мы будем внедрять объекты извне с помощью списка параметров. Разговор будет строиться только вокруг концепции внедре- ния зависимостей. Глава.11..Избегание.зависимостей.и.тесно.связанных.классов 230 Внедрение зависимостей Пример в предыдущем разделе использует композицию (с агрегацией) для того, чтобы наделить класс Dog способностью ходить — поведением Walkable . Класс Dog буквально порождает новый объект Walkab1e внутри самого класса Dog , как показано в следующем фрагменте кода: class Dog { Walkable walker = new Walkable(); } Хотя это вполне рабочий вариант, классы остаются тесно связанными. Чтобы полностью разделить классы в предыдущем примере, нужно привести в действие концепцию внедрения зависимостей, о которой уже упоминалось. Внедрение зависимостей и инверсия управления часто рассматриваются вместе. Одно из назначений инверсии управления (IOC) — назначить кого-либо ответственным за создание экземпляра зависимости и передачу его впоследствии вам. Это именно то, что мы собираемся реализовать в данном примере. Поскольку не все млекопитающие могут ходить, летать или плавать, требуется создать интерфейсы, воссоздающие поведения разного рода млекопитающих, чтобы начать разделение связанных классов. В этом примере я сосредоточусь на поведении, отвечающем за умение ходить, и создам интерфейс IWalkab1e , как показано на рис. 11.3. «интерфейс» IWalkable Dog TestMammal Mammal Extends Рис. 11.3..Использование.интерфейсов.для.создания.. классов.млекопитающих 231 Композиция.против.наследования.и.внедрения.зависимостей. . Код интерфейса IWalkable выглядит так: interface IWalkable { public void walk(); } Единственный метод этого интерфейса — это walk() , который оставили опре- деленному классу для обеспечения реализации. class Dog extends Mammal implements IWalkable; Walkable walker; public void setWalker (Walkable w) { this.walker=w; } public void walk () {System.out.printIn("I am Walking");}; } Обратите внимание, что класс Dog является расширением класса Mammal и обе- спечивает реализацию интерфейса IWalkable . Также обратите внимание, что класс Dog предоставляет ссылку, а конструктор — механизм для внедрения за- висимости. Walkable walker; public void setWalker (Walkable w) { this.walker=w; } Вот что такое внедрение зависимостей в общих чертах. Поведение Walkable не создается внутри класса Dog с помощью ключевого слова new , а внедряется в этот класс через список параметров. Приведем полный пример: class Mammal { public void eat () {System.out.printin("I am Eating") ;}; } interface IWalkable { public void walk(); } class Dog extends Mammal implements IWalkable{ Walkable walker; public void setWalker (Walkable w) { this.walker=w; } public void walk () {System.out.printin("I am Walking") ;}; } public class TestMammal { Глава.11..Избегание.зависимостей.и.тесно.связанных.классов 232 public static void main(String args[]) { System.out.printIn("Composition over Inheritance") ; System.out.printin("\nDog") ; Walkable walker = new Walkable(); Dog fido = new Dog(); fido.setWalker (walker) ; fido.eat(); fido.walker.walk(); } } Несмотря на то что в примере используется внедрение с помощью конструкто- ра, это не единственный способ провести внедрение зависимостей. Внедрение с помощью конструктора Один из способов внедрения поведения Walkable — создание конструктора внутри класса Dog , который при вызове будет принимать аргумент от основно- го приложения следующим образом: class Dog { Walkable walker; public Dog (Walkable w) { this.walker=w; } } При таком подходе приложение создает экземпляр объекта Walkable и вставля- ет его в класс Dog с помощью конструктора. Walkable walker = new Walkable(); Dog fido = new Dog(walker) Внедрение с помощью сеттера Хотя конструктор будет инициализировать атрибуты во время создания объ- екта, на протяжении всего существования объекта зачастую приходится пере- загружать значения. Здесь применяются методы мутаторов — в форме сеттеров. Поведение Walkable можно вставить в класс Dog с помощью сеттера, в данном случае setWalker() : class Dog { Walkable walker; public void setWalker (Walkable w) { this.walker=w; } } 233 Ссылки. . Благодаря конструктору приложение создает объект Walkable и вставляет его в класс Dog с помощью сеттера: Walkable walker = new Walkable(); Dog fido = new Dog(); fido.setWalker (walker); Заключение Внедрение зависимостей снижает связанность конструкции класса благодаря избавлению от зависимостей. Это что-то вроде покупки готовой продукции (от поставщика) вместо создания экземпляра каждый раз собственноручно. Этот вопрос играет ключевую роль в споре о выборе между наследованием и композицией. Важно заметить, что это лишь обсуждение. Цель этой главы заключается не столько в описании «оптимального» способа проектирования классов, сколько в настраивании на мышление в отношении проблем, связанных с выбором между наследованием и композицией. В следующей главе мы углу- бимся в изучение принципов объектно-ориентированного проектирования SOLID, набора концепций, который так высоко признается и ценится сообще- ством разработчиков программного обеспечения. Ссылки Мартин Роберт с соавт. «Гибкая разработка программного обеспечения: прин- ципы, паттерны и инструкции» (Agile Software Development, Principles, Patterns, and Practices). Бостон, штат Массачусетс: PearsonEducation, Inc., 2002. Мартин Р. Чистый код: создание, анализ и рефакторинг. Библиотека програм- миста. — СПб.: Питер, 2018. — 464 с.: ил. Глава 12 ПРИНЦИПЫ ОБЪЕКТНО- ОРИЕНТИРОВАННОГО ПРОЕКТИРОВАНИЯ SOLID Одно из наиболее распространенных утверждений, которые делают многие разработчики в отношении объектно-ориентированного программирования, заключается в том, что его основным преимуществом является моделирование реального мира. Я признаю, что часто использую эти слова, когда рассказываю о классических концепциях объектно-ориентированного программирования. Как считает Роберт Мартин (по меньшей мере так он утверждал в одной из своих лекций на YouTube), основная идея объектно-ориентированного проек- тирования близка к тому взгляду, который распространен в маркетинге. Между тем он утверждает, что объектно-ориентированная разработка в основном ка- сается управления зависимостями посредством инверсии ключевых зависимо- стей в целях предотвращения негибкости кода, его недолговечности, а также невозможности повторного использования кода. Например, в курсах по классическому объектно-ориентированному програм- мированию код часто прямо повторяет ситуации, встречающиеся в жизни. На- пример, если собака — млекопитающее, то очевидно, что для этой связи лучшим выбором будет наследование. Точная проверка на отношение «содержит как часть» и «является экземпляром», подобная лакмусовой реакции, является частью объектно-ориентированного мышления долгие годы. Однако, как мы видели уже на протяжении этой книги, попытки внедрить от- ношение наследования может вызвать проблемы проектирования (вспомните пример с собаками, которые не лают). Действительно ли при проектировании стоит отделить собак, не умеющих лаять, от тех, которые умеют, или летающих птиц от нелетающих при помощи наследования? Было ли это все создано объ- ектно-ориентированными маркетологами? Хорошо, не нужно шумихи. Как мы видели в предыдущей главе, судя по всему, строгая однобокость в выборе между отношениями содержит как часть и является экземпляром далеко не 235 Ссылки. . всегда является лучшим подходом. Похоже, нам стоит больше обращать внима- ние на разделение связанных классов. В лекции, о которой я уже упоминал, Роберт Мартин, которого часто называют Дядей Бобом, использует следующие три термина для описания кода, непри- годного для повторного использования: Негибкость — когда изменение в одной части программы может вызвать сбой в другой части. Недолговечность — когда что-либо «ломается» в местах, не связанных между собой. Ограниченная подвижность — когда код нельзя повторно использовать вне оригинального контекста. Принципы SOLID были созданы для устранения подобных ограничений и достижения высокого качества кода. Роберт Мартин ввел эти пять принци- пов объектно-ориентированной разработки, чтобы «придать исполнению больше рациональности, гибкости и сопровождаемости». По словам Марти- на, принципы SOLID также формируют ядро философии различных методик, например гибкой методологии объектно-ориентированной разработки или адаптивной разработки. Сокращение SOLID появилось благодаря Майклу Фезерсу. Ниже перечислены пять принципов SOLID: Single Responsibility Principle (SRP) — принцип единственной ответствен- ности. Open-Closed Principle (OCP) — принцип открытости/закрытости. Liskov Substitution Principle (LSP) — принцип подстановки Барбары Ли- сков. Interface Segregation Principle (ISP) — принцип разделения интерфейса. Dependency Inversion Principle (DIP) — принцип инверсии зависимостей. В главе рассказывается об этих самых пяти принципах и показана их связь с принципами классического объектно-ориентированного программирования, которые играли важную роль на протяжении десятилетий. Я постараюсь объ- яснить принципы SOLID на максимально простых примерах. В глобальной сети есть много материала на эту тему, в том числе несколько неплохих видео на YouTube. Многие из этих видео нацелены на разработчиков и не всегда будут понятны студентам и новичкам. Как и во всех приведенных ранее примерах в этой книге, я постараюсь не ус- ложнять, а дать суть концепций, максимально упростив конструкцию в образо- вательных целях. Глава.12..Принципы.объектно-ориентированного.проектирования 236 Принципы объектно-ориентированной разработки SOLID В главе 11 «Избегание зависимостей и тесно связанных классов» мы обсуждали некоторые фундаментальные концепции, постепенно подбираясь к обсуждению пяти принципов SOLID. В этой главе мы подробно рассмотрим каждый принцип SOLID. Все характеристики принципов SOLID взяты с сайта Дяди Боба: http:// butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod. 1. SRP: принцип единственной ответственности Принцип единственной ответственности гласит о том, для внесения изменений в класс требуется только одна причина. Каждый класс и модуль программы должны иметь в приоритете одно задание. Поэтому не стоит вносить методы, которые могут вызвать изменения в классе более чем по одной причине. Если описание класса содержит слово «and», то принцип SRP может быть нарушен. Другими словами, каждый модуль или класс должен нести ответственность за одну какую-либо часть функционала программного обеспечения, и такая от- ветственность должна быть полностью инкапсулирована в класс. Создание иерархии фигур — это один из классических примеров, иллюстриру- ющих наследование Этот пример часто встречается в обучении, а я использую его на протяжении этой главы (равно как и всей книги). В этом примере класс Circle наследует атрибуты от класса Shape . Класс Shape предоставляет абстракт- ный метод calcArea() в качестве контракта для подкласса. Каждый класс, на- следующий от Shape , должен иметь собственную реализацию метода calcArea() : abstract class Shape{ protected String name; protected double area; public abstract double calcArea(); } В этом примере класс Circle , наследующий от класса Shape , при необходимости обеспечивает свою реализацию метода calcArea() : class Circle extends Shape{ private double radius; public Circle(double r) { radius = r; } public double calcArea() { area = 3.14*(radius*radius) ; return (area); }; } 237 Принципы.объектно-ориентированной.разработки.SOLID. . ПРЕДОСТЕРЕЖЕНИЕ __________________________________________________________________ В.этом.примере.мы.только.собираемся.рассмотреть.класс. Circle ,.чтобы.сосредо- точиться.на.принципе.единственной.ответственности.и.сделать.пример.максималь- но.простым.. Третий класс, CalculateAreas , подсчитывает площади различных фигур, со- держащихся в массиве Shape . Массив Shape обладает неограниченным размером и может содержать различные фигуры, например квадраты и треугольники. class CalculateAreas { Shape[] shapes; double sumTotal=0; public CalculateAreas(Shape[] sh) { this.shapes = sh; } public double sumAreas() { sumTotal=0; for (inti=0; i } return sumTotal ; } public void output() { System.out.printIn("Total of all areas = " + sumTotal); } } Обратите внимание, что класс CalculateAreas также обрабатывает вывод при- ложения, что может вызвать проблемы. Поведение подсчета площади и поведе- ние вывода связаны, поскольку содержатся в одном и том же классе. Мы можем проверить работоспособность этого кода с помощью соответствую- щего тестового приложения TestShape : public class TestShape { public static void main(String args[]) { System.out.printin("Hello World!"); Circle circle = new Circle(1); Shape[] shapeArray = new Shape[1]; shapeArray[0] = circle; CalculateAreas ca = new CalculateAreas(shapeArray) ; ca.sumAreas() ; ca.output(); } } Глава.12..Принципы.объектно-ориентированного.проектирования 238 Теперь, имея в распоряжении тестовое приложение, мы можем сосредото- читься на проблеме принципа единственной ответственности. Опять же, проблема связана с классом CalculateAreas и с тем, что этот класс содержит поведения и для сложения площадей различных фигур, а также для вывода данных. Основополагающий вопрос (и, собственно, проблема) в том, что если нужно изменить функциональность метода output() , потребуется внести изменения в класс CalculateAreas независимо от того, изменится ли метод подсчета пло- щади фигур. Например, если мы вдруг захотим осуществить вывод данных в HTML-консоль, а не в простой текст, нам потребуется заново компилировать и повторно внедрять код, который складывает площади фигур. Все потому, что ответственности связаны. В соответствии с принципом единственной ответственности, задача состоит в том, чтобы изменение одного метода не повлияло на остальные методы и не приходилось проводить повторную компиляцию. «У класса должна быть одна, только одна, причина для изменения — единственная ответственность, которую нужно изменить». Чтобы решить данный вопрос, можно поместить два метода в отдельные классы, один для оригинального консольного вывода, другой для вывода в HTML: class CaiculateAreas {; Shape[] shapes; double sumTotal=0; public CalculateAreas(Shape[] sh) { this.shapes = sh; } public double sumAreas() { sumTotal=0; for (inti=0; i } return sumTotal; } } class OutputAreas { double areas=0; public OutputAreas (double a) { this.areas = a; } public void console() { |