Сегодня трудно найти программиста, который бы не слышал о паттернах проектирования в ООП. Тем не менее, очень часто приходится сталкиваться с непониманием или неумением применять конкретные паттерны для решения конкретных задач. В этой статье я постараюсь провести ликбез по некоторым из них.
Для примеров я буду использовать язык Java. Он, с одной стороны, достаточно распространён и прост для понимания, с другой – лишён средств, которые могли бы помочь решить описываемую задачу без применения паттернов. Соответственно, на другие ОО-языки примеры, как правило, можно либо переносить напрямую, либо записывать ещё проще.
Вступление, или что такое ООП
Для понимания большинства паттернов необходимо сразу же ввести некоторые понятия и определения, а также правила написания программ на объектно-ориентированных языках. Вы можете соглашаться или не соглашаться с ними, однако без них приведённые примеры будут казаться странными и неоправданно сложными. Как известно, каждый объект в программе имеет некоторый класс или тип*. Тип определяет то, какие значения (состояния) может принимать объект. Например, объект типа ЦелоеЧисло может принимать значения 1, 2, 3, а также -1, -2, -3 и так далее. Интерфейсом** объекта мы назовём набор операций (методов), которые он может совершать или которые можно совершать над ним. Например, интерфейс числа 3 называется, собственно, «Число» и включает в себя набор операций сложения, вычитания, умножения и деления. Реализовать этот интерфейс может любое количество типов/классов, например, типы ЦелоеЧисло, ВещественноеЧисло, КомплексноеЧисло, а также такие неочевидные типы как Интервал или Полином. В то же время, один и тот же тип может реализовать более чем один интерфейс. Так, ЦелоеЧисло кроме интерфейса Число может реализовать интерфейс Перечислимый – для этого в классе ЦелоеЧисло будет необходимо всего лишь доопределить операции «следующий» и «предыдущий». Несмотря на то, что в Java интерфейс имеет прямое отражение в виде соответствующей конструкции, его не обязательно объявлять для каждого типа. Если этот интерфейс реализуется всего одним классом, то набор публичных методов этого класса и будет составлять его интерфейс. Принцип программирования через интерфейсы предполагает написание кода без учёта внутренней структуры объекта. Это может выражаться и как простая инкапсуляция, и как написание кода без расчёта на конкретный тип. Например, вы повсеместно в программе для хранения некоторого списка использовали тип ArrayList, а затем внезапно обнаружили, что работа с ним в 99% случаев ведётся как со стеком, и поэтому было бы неплохо заменить его на LinkedList, дабы повысить производительность. Что делать в таком случае? Бегать по всему коду и переименовывать переменные? Это долго и мучительно. И ещё ничего, если это касается только вашего кода, а что если ваш код опирается на библиотеки, в котором в сигнатуре жёстко забит тип ArrayList вместо обобщённого List? В таком случае вам придётся производить дополнительные операции по преобразованию типа, которые плохо скажутся и на производительности, и на читабельности, и на количестве строк кода. При этом для решения проблемы достаточно было везде вести работу с абстрактным интерфейсом List. Как отмечают GoF, программирование через интерфейсы настолько существенно уменьшает число зависимостей между подсистемами, что этот принцип можно назвать основным для объектно-ориентированного программирования с целью повторного использования кода. Принцип разделения наследования класса и наследования интерфейса гласит, что наследование класса должно быть использовано только в том случае, когда вам нужно повторно использовать большую часть реализации другого класса. Если вам нужен лишь небольшой кусок функциональности другого класса, лучше использовать агрегацию и делегирование. Если ваша цель – скопировать интерфейс другого объекта, а большинство методов вы собираетесь перегрузить, то лучше вынести этот интерфейс в отдельное объявление и реализовать его обоими классами. С другой стороны, наследование интерфейсов не имеет никаких ограничений и может быть свободно использовано при первой необходимости (хотя на практике такое происходит не так часто). Все паттерны возникли при решении конкретных задач, т. е. это задача всегда определяет правила написания кода, а не наоборот. Поэтому я не вижу смысла начинать объяснения с описания самих паттернов. Вместо этого я предлагаю решить реальную задачу и посмотреть, какие из паттернов могут быть при этом использованы. В качестве задачи возьмём небольшую библиотеку для обработки фильмографий, которую мне когда-то довелось писать. Оговорюсь, что писал я её на Python, а не на Java, тем не менее, для целей описания паттернов это не имеет значения. Библиотека служила для того, чтобы искать на Википедии статьи о фильмах, извлекать из них информацию и предоставлять её в удобном виде (XML, JSON, объекты предопределённого класса и т.д.). Несмотря на простоту, задача изобилует распространёнными проблемами и, соответственно, паттернами, их решающими.Template Method, или Как сделать фреймворк
Очевидно, что для разбора страницы нам понадобится HTML-парсер. На своём опыте убедился, что с «грязными» документами вроде HTML-страниц гораздо лучше справляются событийные SAX-парсеры, чем строящие дерево DOM. Если кто не в курсе, работает это так: парсер последовательно сканирует текст, и когда встречает новый элемент (открывающий тег, закрывающий тег, текст, комментарий и т.д.), вызывает обработчик. При этом ваша задача сводится к тому, чтобы написать только эти самые обработчики, а весь код сканирования текста уже включён в библиотеку. Например, при использовании библиотеки HotSAX код будет выглядеть примерно так:import org.xml.sax.ContentHandler; … public class WikiFilmHandler implements ContentHandler { … public void startElement(String uri, String localName, String qName, Attributes attributes) { // handle start element } public void characters(char[] text, int start, int end) { // handle text } ... }
Builder, или Что нам стоит фильм построить
Однако одного парсера нам будет недостаточно. Что будет, если Википедия вдруг поменяет формат полей, содержащих информацию о фильме? Или мы захотим собирать информацию не с Википедии, а с какого-нибудь IMDB? Или вообще решим добавить возможность составления не только фильмографий, но и дискографий? Для всех этих случаев понадобится свой отдельный парсер. Это делает следующий шаг очевидным: необходимо отделить особенности представления конструируемого медиа объекта от алгоритма его конструирования. Другими словами, необходимо вынести всю работу по разбору конкретной страницы в отдельный класс, передать объект этого класса параметром в процедуру парсинга, а затем забрать у него результат. Возвращаясь к примеру с домом, можно сказать, что вы заказываете у разных мастеров-строителей двери, окна, подвесные потолки и так далее, а затем отдаёте их основной строительной команде, которая уже встраивает всё это в каркас. Это и является основной идеей паттерна Builder (Строитель). В случае HotSAX в роли мастеров-строителей выступают хендлеры. Мы передаём их «главному прорабу» – процедуре parsePage, чтобы они под его чётким руководством изготовили наши «окна» и «двери» – классы Film, Song, Album и т.д. В нашем случае также полезно объединить перечисленные классы под общим интерфейсом – MediaObject:public interface MediaHandler extends ContentHandler { public MediaObject getResult(); } public class WikiFilmHandler implements MediaHandler { ... }
public MediaObject parsePage(InputStream page, MediaHandler handler) throws Exception { XMLReader parser = XMLReaderFactory.createXMLReader("hotsax.html.sax.SaxParser"); parser.setContentHandler(handler); parser.parse(page); return handler.getResult(); }
Adapter, или Вкручиваем гвозди и забиваем болты
Приступая к работе над очередным сайтом-источником информации о фильмах, мы случайно обнаруживаем, что кто-то уже разобрался с ним и даже оформил свою работу в виде библиотеки хендлеров. Только есть одна проблема: хендлеры не реализуют наш интерфейс MediaHandler (что, в общем-то, не удивительно), и, следовательно, не могут быть переданы нашему парсеру. При этом оформлены они в виде скомпилированных классов без исходного кода. Выхода из этой ситуации два, однако, декомпиляция исходников, как правило, является незаконной и однозначно не приветствуется авторами библиотек, поэтому остаётся один вариант: использовать Adapter (Адаптер). Паттерн Adapter делает именно то, что и означает его название: адаптирует интерфейс одного класса к интерфейсу другого. Реализуется он в виде дополнительного класса-посредника. Посредник имплементирует требуемый интерфейс (в нашем случае – MediaHandler ), однако всю реальную работу перепоручает другому классу (готовым хендлерам из библиотеки). Здесь есть два варианта: либо агрегировать хендлер из библиотеки и в каждом из требуемых методов делегировать ему обработку, либо унаследовать адаптер от хендлера, и в объявлении прописать, что посредник имплементирует нужный интерфейс. Оба варианта имеют свои плюсы и минусы. Например, агрегирование требует написания кучи кода, единственная цель которого – вызывать другой код. Наследование лишено этого недостатка, зато не может быть использовано, если сигнатуры методов в двух интерфейсах различаются. Кроме того, если хендлеров много, то при агрегировании конкретный хендлер может быть передан адаптеру в качестве аргумента, и, таким образом, один адаптер сможет обрабатывать сразу множество хендлеров, в то время как при наследовании придётся создавать отдельный класс-адаптер для каждого интерфейса.Observer / Publish-Subscribe, или Дайте нам о себе знать
В отличие от предыдущих паттернов, описание этого я начну не с проблемы, а с применения. Паттерн Observer (Наблюдетель) чаще всего описывают на примере модели MVC, где множество отображений (Views) «наблюдает» за моделью, и изменение на одном из них тут же отображается на остальных. Однако здесь я хотел бы показать другой пример использования этого паттерна, который лучше выражается названием Publish-Subscribe (Издатель-Подписчик). Предположим, что у нас уже накопилось значительное количество хендлеров, и они продолжают появляться. Как сделать так, чтобы основная программа всегда знала обо всех доступных обработчиках? Это довольно просто реализуется, если завести в программе реестр*** обработчиков. Например, мы можем создать в своей программе класс HandlersRegistry:public class HandlersRegistry { private Maphandler = new HashMap (); public void register(String regexp, MediaHandler handler) { handlers.put(regexp, handler); } public void unregister(String regexp) { handlers.remove(regexp); } … }
Все более или менее опытные программисты так или иначе используют в своей работе паттерны, зачастую даже не догадываясь об этом. Я надеюсь, что этот материал поможет людям, не знакомым с GoF и подобной литературой упорядочить свои знания и более чётко выделять паттерны в своих проектах.
* - исключение составляют разве что программы на brainfuck или языках некоторых ассемблеров, но к ООП они, так или иначе, не имеют никакого отношения. ** - эти определения могут показаться банальными, однако в различной литературе использованные термины имеют различные значения, причем, иногда пересекающиеся. Так, например, GoF называет типом часть интерфейса, но никак не класса; в то же время в языке Haskell тип относится к реализации, а вот слово «класс», по сути, - это аналог интерфейса. *** - ещё одно удачное, на мой взгляд, название для этого паттерна – Registry (Реестр, Регистратура)
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.