Мягкое введение в паттерны проектирования. Часть 1.

11 июня 2010, 23:10

Design patterns

Сегодня трудно найти программиста, который бы не слышал о паттернах проектирования в ООП. Тем не менее, очень часто приходится сталкиваться с непониманием или неумением применять конкретные паттерны для решения конкретных задач. В этой статье я постараюсь провести ликбез по некоторым из них.

Для примеров я буду использовать язык 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
    }
    ...
}
Причём же здесь паттерны? А при том, что класс WikiFilmHandler представляет собой ничто иное как реализацию паттерна Template Method (Шаблонный метод). Суть его в том, что вы реализуете большую часть некоторого алгоритма (в данном случае – алгоритма разбора страницы, метод parse), но оставляете пользователю вашего класса возможность переопределить отдельные его шаги (здесь – обработку конкретных событий). Template method В случае HotSAX основной алгоритм зашит в отдельном классе - hotsax.html.sax.SaxParser, так что если быть совсем точным, то данный шаблон реализуется не одним классом, а именно связкой библиотечного парсера и рукописного хэндлера. В данном случае достаточно, чтобы класс, отвечающий за изменяемые шаги алгоритма (WikiFilmHandler), реализовал определённый интерфейс (ContentHandler). Чаще встречается вариант, когда для использования готового алгоритма нужно унаследовать свой класс от того, в котором это уже реализовано (помните принцип наследования реализации?). Так, например, для создания сервлета вам необходимо расширить класс HttpServlet и переопределить в нём методы doGet, doPost и т.д. Обратите внимание, что код, который вы пишете (обработчики событий), является вызываемым. То есть, вы пишете отдельные куски, а объединяющий и вызывающий их код находится в стандартном шаблонном методе. Это тот же принцип, что положен в основу всех фреймворков: ядро едино и неизменно, а пользовательский код подключается к нему в виде отдельных компонент. Если хотите ещё одно сравнение, то шаблонный метод/фреймворк похож на каркас дома массовой застройки: изначально все каркасы одинаковы, но каждый житель «конфигурирует» его под себя – добавляет двери и окна, обставляет интерьер и т.д. В итоге из одной и той же стандартной коробки получаются совершенно разные постройки.

Builder, или Что нам стоит фильм построить

Однако одного парсера нам будет недостаточно. Что будет, если Википедия вдруг поменяет формат полей, содержащих информацию о фильме? Или мы захотим собирать информацию не с Википедии, а с какого-нибудь IMDB? Или вообще решим добавить возможность составления не только фильмографий, но и дискографий? Для всех этих случаев понадобится свой отдельный парсер. Это делает следующий шаг очевидным: необходимо отделить особенности представления конструируемого медиа объекта от алгоритма его конструирования. Другими словами, необходимо вынести всю работу по разбору конкретной страницы в отдельный класс, передать объект этого класса параметром в процедуру парсинга, а затем забрать у него результат. Возвращаясь к примеру с домом, можно сказать, что вы заказываете у разных мастеров-строителей двери, окна, подвесные потолки и так далее, а затем отдаёте их основной строительной команде, которая уже встраивает всё это в каркас. Это и является основной идеей паттерна Builder (Строитель). 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();
}
Другим местом, где мы можем использовать этот подход, является генерация конечного представления — XML, JSON и т. д. Правда, в этом случае метод getResult может быть заменён рядом методов getXML, getJSON и т.д., не имеющих общего интерфейса.

Adapter, или Вкручиваем гвозди и забиваем болты

Приступая к работе над очередным сайтом-источником информации о фильмах, мы случайно обнаруживаем, что кто-то уже разобрался с ним и даже оформил свою работу в виде библиотеки хендлеров. Только есть одна проблема: хендлеры не реализуют наш интерфейс MediaHandler (что, в общем-то, не удивительно), и, следовательно, не могут быть переданы нашему парсеру. При этом оформлены они в виде скомпилированных классов без исходного кода. Выхода из этой ситуации два, однако, декомпиляция исходников, как правило, является незаконной и однозначно не приветствуется авторами библиотек, поэтому остаётся один вариант: использовать Adapter (Адаптер). AdapterПаттерн Adapter делает именно то, что и означает его название: адаптирует интерфейс одного класса к интерфейсу другого. Реализуется он в виде дополнительного класса-посредника. Посредник имплементирует требуемый интерфейс (в нашем случае – MediaHandler ), однако всю реальную работу перепоручает другому классу (готовым хендлерам из библиотеки). Здесь есть два варианта: либо агрегировать хендлер из библиотеки и в каждом из требуемых методов делегировать ему обработку, либо унаследовать адаптер от хендлера, и в объявлении прописать, что посредник имплементирует нужный интерфейс. Оба варианта имеют свои плюсы и минусы. Например, агрегирование требует написания кучи кода, единственная цель которого – вызывать другой код. Наследование лишено этого недостатка, зато не может быть использовано, если сигнатуры методов в двух интерфейсах различаются. Кроме того, если хендлеров много, то при агрегировании конкретный хендлер может быть передан адаптеру в качестве аргумента, и, таким образом, один адаптер сможет обрабатывать сразу множество хендлеров, в то время как при наследовании придётся создавать отдельный класс-адаптер для каждого интерфейса.

Observer / Publish-Subscribe, или Дайте нам о себе знать

В отличие от предыдущих паттернов, описание этого я начну не с проблемы, а с применения. Паттерн Observer (Наблюдетель) чаще всего описывают на примере модели MVC, где множество отображений (Views) «наблюдает» за моделью, и изменение на одном из них тут же отображается на остальных. Однако здесь я хотел бы показать другой пример использования этого паттерна, который лучше выражается названием Publish-Subscribe (Издатель-Подписчик). Предположим, что у нас уже накопилось значительное количество хендлеров, и они продолжают появляться. Как сделать так, чтобы основная программа всегда знала обо всех доступных обработчиках? Это довольно просто реализуется, если завести в программе реестр*** обработчиков. Например, мы можем создать в своей программе класс HandlersRegistry:
public class HandlersRegistry {
	private Map handler = new HashMap();
	public void register(String regexp, MediaHandler handler) {
	        handlers.put(regexp, handler);
        }
        
	public void unregister(String regexp) {
		handlers.remove(regexp);
	}
	…
}
SubscriberОбъект handlers хранит мэппинг регулярных выражений, соответствующих адресам сайтов, к соответствующим обработчикам. Попасть в этот мэппинг, как и выйти из него, можно единственным способом – посредствам процедур register и unregister, что соответствует добавлению в и удалению из реестра. Далее приложение получает на вход URL страницы, обращается в реестр за нужным классом и разбирает его соответствующим MediaHandler’ом. Подчеркну: основной идеей паттерна является возможность множеству объектов динамически сообщать о себе основному классу. Это похоже на то, как подписчики газет (рассылок, новостных лент) сообщают о себе издателю, откуда и идёт название паттерна. Несмотря на свою простоту, такой подход имеет множество применений, от организации систем оповещения до широковещания и массовых рассылок.

Все более или менее опытные программисты так или иначе используют в своей работе паттерны, зачастую даже не догадываясь об этом. Я надеюсь, что этот материал поможет людям, не знакомым с GoF и подобной литературой упорядочить свои знания и более чётко выделять паттерны в своих проектах.


* - исключение составляют разве что программы на brainfuck или языках некоторых ассемблеров, но к ООП они, так или иначе, не имеют никакого отношения. ** - эти определения могут показаться банальными, однако в различной литературе использованные термины имеют различные значения, причем, иногда пересекающиеся. Так, например, GoF называет типом часть интерфейса, но никак не класса; в то же время в языке Haskell тип относится к реализации, а вот слово «класс», по сути, - это аналог интерфейса. *** - ещё одно удачное, на мой взгляд, название для этого паттерна – Registry (Реестр, Регистратура)
Обсуждение