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

28 июня 2010, 01:09
Bruce lee in java В своей незаконченной книге по Джит Кун До известный актёр и боец Брюс Ли написал: «До изучения боевого искусства удар рукой был для меня просто ударом рукой, а удар ногой просто ударом ногой. После изучения боевого искусства удар рукой перестал быть ударом рукой, а удар ногой - ударом ногой. Теперь, когда я понимаю боевое искусство, удар рукой - это просто удар рукой, удар ногой - просто удар ногой». Может показаться странным начинать статью про паттерны проектирования в ООП с цитаты Брюса Ли, но именно это высказывание отражает суть процесса, с которым сталкиваются люди, начинающие систематически изучать то, что раньше делали не задумываясь.

Введение, или Моё кунг-фу сильнее твоего

Если вы когда-либо занимались боевыми искусствами, то наверняка запомнили то ощущение «неправильности» всех движений, которые вы делали до прихода в секцию или школу. «Ты неправильно стоишь», «ты неправильно бьёшь», «так делать нельзя, надо делать вот так» – примерно такие фразы, скорее всего, говорил вам тренер. И вы в точности следовали его инструкциям, переделывая свои «неправильные» удары, которые, тем не менее, много раз спасали вас на улицах, в «правильные», хотя и неудобные и неестественные. Через месяц или два занятий вы случайно ввязываетесь в потасовку, где как раз и должны бы пригодиться новые знания, но вместо того, чтобы помочь вам, «правильные», но всё ещё неудобные движения замедляют и сковывают вас. После нескольких неудачных попыток применить «крутой приём» вы бросаете это дело и начинается драться так, как привыкли. И побеждаете. Так что же? Вы зря занимались и все боевые искусства – это чушь? Примерно в такую же ситуацию часто попадают люди, изучающие паттерны проектирования. Вначале они «отрабатывают» определённые паттерны, стараясь применить их везде, где это возможно, попутно пытаясь отучиться от «неправильного» стиля программирования. Паттерны кажутся неестественными и надуманными, но ведь большой умный «тренер» (старший товарищ, менеджер, автор книги) сказал, что так надо, значит так надо. В какой-то момент эти люди начинают чувствовать подвох. Модуль становится всё сложнее и сложнее модифицировать, а затраты на поддержку паттерна становятся сравнимы с затратами на переписывание всего модуля без него. В конечном итоге они отказываются от изначальной идеи и выбрасывают из кода все паттерны. И, voilà! Система становится проще, красивей, да ещё и производительней впридачу. Значит ли это, что паттерны – зло? Нет, не значит. Это значит лишь то, что цепочка размышлений при проектировании системы была выстроена неверно. Паттерны становятся культом, и тогда порядок мыслей выглядит так: Wrong order of thoughts вместо того чтобы выглядеть так: Right order of thoughts Другими словами, не нужно искать паттерны там, где их нет. Равно как и пытаться соблюсти все правила их написания. Тогда, когда вы поймёте паттерны, Адаптер станет для вас просто адаптером между двумя интерфейсами, а Стратегия1 - просто стратегией некоторого действия.

Factory Method, или Существительные и глаголы

Однако всё это философия и общая теория. Следовать ей, несомненно, надо, но также надо и делать что-то на практике. А на практике мы будем писать библиотеку для моделирования автомобилей. Суть в том, чтобы дать пользователю нашей библиотеки возможность создавать автомобили, модернизировать их, замерять параметры и даже проводить соревнования между разными марками. Обратите внимание: писать мы будем не законченную программу, а именно библиотеку, а это значит, что мы не сможем делать никаких предположений насчёт того, откуда и в каком контексте будет вызван наш код. Это также значит, что мы будем обязаны сохранить обратную совместимость при выходе новой версии нашей библиотеки. Итак, начнём. В соответствии с принципом программирования через интерфейсы, который мы определили в части 1, сразу же создадим интерфейс Car и заставим все классы автомобилей его реализовать. Код для работы с автомобилями, в таком случае, будет выглядеть примерно так: Car c1 = new Ford(); Car c2 = new Audi(); Car c3 = new Ferrari(); // do something with the cars От каждого класса автомобиля могут быть унаследованы другие классы, уточняющие его. Например, у класса Ford могут быть наследники FordFocus, FordFiesta, FordTaurus и т.д. Хорошо, если вам и пользователям вашей библиотеки известно, какие именно классы машин необходимо использовать в коде, однако, что делать, если конкретные классы во время написания кода неизвестны? Например, если вы хотите эмулировать соревнования по скорости между двумя марками автомобилей, вы наверняка захотите выбрать для этого самые быстрые модели из своих семейств. Можно, конечно, просчитать вручную все скорости и подставить в нужные места конкретные классы, но что делать, если пользователь вашей библиотеки захочет переопределить это поведение? Естественный выход – инкапсулировать знание о выборе конкретной модели в отдельном методе и позволить пользователю переопределять его. Код создания экземпляра автомобиля будет выглядеть так: Car fastestFord = fordFactory.createFastest(); Переменная fordFactory соответствующего класса FordFactory называется фабрикой, а метод createFastest – Фабричным методом (Factory Method). О фабриках речь пойдёт в следующем разделе, а вот о методах поговорим сейчас. Так какие конкретно преимущества дало нам использование отдельного метода вместо обычного конструктора? Во-первых, в нашем примере метод инкапсулирует логику выбора класса, что было бы невозможно осуществить в конструкторе. А во-вторых, он позволяет отложить вопрос инстанциирования до момента написания клиентского кода или, при желании, даже до рантайма. Кроме того, если оторваться от данного примера, можно увидеть ещё несколько преимуществ:
  • порождающий метод, в отличие от конструктора, может иметь любое имя, не обязательно совпадающее с именем класса. Следовательно, для создания объекта можно использовать методы с разными именами, но одинаковым списком параметров. На английской Википедии есть пример с комплексными числами: вместо одного конструктора Complex(double, double) используется два статических метода – fromCartesian и fromPolar;
  • конструктор невозможно передать в качестве параметра (для обычных функций это реализуется посредством паттерна Стратегия (Strategy), который описан ниже);
  • порождающий метод не имеет ограничений, присущих конструктору, таких, как обязательная виртуальность, невозможность вернуть пустую ссылку (null), невозможность быть описанными в интерфейсе и пр;
  • метод может создавать объект способом, отличным от стандартного. Например, вместо обычного выделения памяти и запуска кода инициализации, метод может клонировать некоторый объект с уже инициализированными полями2.
Стоит также отметить, что этот паттерн является составной частью многих других, в том числе паттернов Abstract Factory, Prototype и Singleton, о которых речь пойдёт дальше.

Abstract Factory, или Берегите свою семью, дон Карлеоне

Итак, мы можем создавать автомобили, заставлять их ездить, считывать показатели, сравнивать между собой и т.д. Но что, если мы захотим протюнинговать свою машину? Разумеется, для этого понадобится влезть в конструкцию и заменить некоторые запчасти. Запчасти! Именно они нам и нужны. Однако вряд ли колёса от Land Rover'а подойдут для Lamborghini (по крайней мере, это будет смотреться неэстетично). Следовательно, нам нужен некий механизм, который бы позволил разделять «семейства» запчастей таким образом, чтобы можно было сохранить совместимость и правильную работу всех деталей внутри таких «семей». И здесь нам дважды повезло. Во-первых, в реальном мире уже есть нужная нам модель: совместимость запчастей обеспечивают сами производители автомобилей, так что нам никто не мешает проделать такой же трюк и выделить каждого из таких «производителей» в отдельный класс. Во-вторых, по сути, эту проблему мы уже решили на предыдущем шаге, когда говорили про Фабричный метод: каждая фабрика (например, FordFactory, LandRoverFactory, LamborghiniFactory) уже является производителем автомобилей, и было бы логично повесить задачу производства деталей на те же самые классы. Это и называется Абстрактной фабрикой (Abstract Factory). Несмотря на мою нелюбовь к UML-диаграммам при объяснении паттернов, я вынужден признать, что в данном случае они наилучшим образом отражают суть это приёма проектирования. Abstract factory Абстрактная фабрика задаёт интерфейс для создания семейств совместимых объектов. Каждый элемент такого семейства знает всё о «членах своей семьи». Класс автомобиля Lamborghini может знать, куда и как именно установить колёса LamborghiniWheel и какие для этого можно вызывать методы. Например, он может вызвать у LamborghiniWheel метод changeTyre() и не заботиться о том, что такой метод не определён в классах AbstractWheel и других его наследниках. Фабрики и Фабричные методы особенно полезны при конфигурировании системы через внешние настроечные файлы. Например, Hibernate настраивает свои SessionFactory's при инициализации приложения, а уже настроенная SessionFactory отдаёт пользователю объект с интерфейсом Session и реальным типом, зависящим от драйвера базы данных, прописанного в конфиге. Таким образом, логика выбора конкретного класса вообще выносится за пределы программного кода. Абстрактная фабрика и Фабричный метод тесно связаны, и не всегда можно сделать чёткое разделение между ними, поэтому во многих источниках они описываются одним именем (в основном, Абстрактной фабрикой). Интересно, что некоторые языки программирования имеют конструкции, напрямую реализующие эти паттерны. Так, например, в Common Lisp порождение всех объектов осуществляется единой функцией – make-instance, аргументом которой является символ3 нужного класса. Этот символ можно передавать как параметр, закладывать в конфиги, сам метод make-instance можно перекрыть или декорировать4. В итоге, практически неограниченная свобода по созданию класса. В языках ML-семейства есть прямое отражение Абстрактной Фабрики. Например, в Haskell, есть конструкция data5, имеющая следующий синтаксис: data Color = Red | Yellow | Green | Blue | Violet | RGB Int Int Int data Color – объявление «интерфейса» для всех наших классов, а Red, Yellow и т.д. – конкретных типов. Вообще Хаскелл имеет очень развитую типизацию, в том числе, он использует довольно необычный подход к реализации ООП. Однако это тема для отдельного разговора, а мы вернёмся к нашим автомобилям.

Singleton, или Оставьте меня одного!

Фабрики – это, конечно, хорошо, но как-то плодить кучу инстансов одной фабрики, чтобы они делали одно и то же, мягко говоря, не кошерно. Было бы неплохо иметь в системе ровно по одному экземпляру каждой используемой фабрики. Как это сделать? Можно объявить все фабричные методы статическими. Этим мы гарантируем «неразмножение» инстансов, но ценой за это будет невозможность описать данные методы в интерфейсе, а это убийство всей идеи Абстрактной фабрики. Мы также можем инстанциировать все фабрики при загрузке приложения и пользоваться только ими, но если нам нужны всего 3 фабрики, а в системе их объявлено 50, то 47 из них будут просто засорять память. Со всеми этими проблемами успешно борется паттерн Одиночка (Singleton): public class Singleton { private static Singleton singleton; private Singleton(){} // (0) public static volatile Singleton getInstance(){ // (1) if (singleton == null) { // (2) synchronized(Singleton.class){ // (3) if (singleton == null) { // (4) singleton = new Singleton(); // (5) } } } return singleton; } } Суть паттерна в том, чтобы запретить прямое создание объекта, сделав его конструктор невидимым извне (0), а для создания определить статический фабричный метод getInstance() (1). Внутри этого метода мы создаём объект синглтона и сохраняем его в соответствующую переменную. Поскольку метод может быть вызван из разных потоков, сохранение объекта в переменную производится в блоке синхронизации (3). Может оказаться так, что перед самой синхронизацией какой-то поток всё же успел создать и сохранить инстанс объекта, поэтому мы на всякий случай проверяем это (4). Эта проверка является обязательной, в отличие от первой проверки на null (2). Для чего же нужна первая проверка? Дело в том, что синхронизация – очень «тяжёлая» операция. Процессор обновляет значение синхронизируемой переменной, а для этого сбрасывает и перезаписывает целую строку своего кэша. В 99% запросов к getInstance объект уже будет существовать, поэтому дополнительная проверка значительно ускорит вызов этого метода. upd. Как правильно заметил nobullet, для правильной синхронизации поле singleton также должно иметь модификатор volatile, гарантирующий, что обращение к этой переменной будет таким же, как если бы оно было заключено в блок synchronized. Без этого модификатора ошибка может произойти в следующем случае. Пусть T1 и T2 - это потоки, выполняемые на процессорах P1 и P2 соответственно, и пытающиеся одновременно выполнить код метода getInstance.
  1. T1 входит в критическую секцию, выделяет под новый объект память и записывает её адрес в переменную singleton, однако ещё не успевает вызвать конструктор;
  2. T2, не доходя до критической секции, считывает значение переменной singleton, которое уже не равно null, но указывает на неинициализированный объект;
  3. T2 не ждёт входа в критическую секцию и возвращает ссылку на "недоделанный" объект вызывающей функции;
  4. T1 завершает создание объекта и выходит из критической секции.
Если вызывающая функция потока T2 успеет использовать объект до того, как T1 завершит его инициализацию (что вполне вероятно в случае, когда P1, например, переключится на выполнение другого потока), то полученные данные будут неверны. На самом деле в этом примере не один, а сразу три паттерна. Описанный механизм с двойной проверкой называется Double-checked Locking, а идея инициализировать значение переменной singleton не при загрузке приложения, а при первом обращении к ней носит имя Ленивой инициализации (Lazy initialization) и является частью целой парадигмы так называемых ленивых вычислений. Может возникнуть подозрение, что метод getInstance не решает проблему, поскольку является статическим и как любой статический метод не может быть описан в интерфейсе AbstractCarFactory. Отчасти это так – getInstance будет отсутствовать в интерфейсе, однако нам это ничуть не помешает, поскольку в AbstractCarFactory должны быть описаны только те методы, которые мы сможем вызывать у любой фабрики, не зная её конкретного класса, а при вызове getInstance мы так или иначе будем иметь дело с некоторой конкретной фабрикой. Код в таком случае будет выглядеть так: AbstractCarFactory factory = LandRoverFactory.getInstance(); // AbstractCarFactory factory = LamborghiniFactory.getInstance(); … Car car = factory.createCar(); // здесь мы уже не знаем конкретного класса фабрики, поэтому можем вызывать только методы, описанные в AbstractCarFactory Следует отметить, что такие танцы с бубном нужны в первую очередь из-за отсутствия в современных ОО-языках классического понятия модуля. Модуль, такой как, например, unit в Pascal – это по-умолчанию объект, существующий в системе в единственном экземпляре. Он примерно равен классу со всеми статическими методами в Java (такому как java.lang.Math), и поэтому не может быть описан в интерфейсе, однако в рамках процедурного программирование и самого понятия интерфейса, как правило, не существует.

Strategy, или И каждый мнит себя стратегом…

Конечно, фабрики решили проблемы с перегрузкой методов, но:
  1. если наследовать каждый раз, когда нам нужно заменить один метод, это приведёт к комбинаторному росту количества классов;
  2. наследование производится статически, во время компиляции, а что делать, если мы хотим заменить некоторый метод прямо во время работы программы?
Предположим, что мы хотим получить некоторую настраиваемую фабрику, которая могла бы, при правильной конфигурации, производить автомобили любых марок, а также запчасти к ним. Что нам нужно, так это возможность заменять отдельные методы. Помните, в самом начале я говорил, что выбрал для примеров язык Java из-за отсутствия в нём средств для решения поставленных проблем без применения паттернов? Так вот, это, пожалуй, наиболее яркий случай. Если бы мы программировали на Си, то использовали бы указатели на функции. В C# для этого есть делегаты. Delphi – процедурные переменные. В Python и всех функциональных языках любая функция является полноценной переменной. Однако мы пишем на Java, где ничего из вышеперечисленного нет6. По сути, всё, что мы можем передать в качестве параметра – это объект. Но ведь объект может содержать в себе метод! Создавать классы с единственным методом кажется странным, тем не менее, именно это и является сущностью паттерна Стратегия (Strategy). Для его реализации достаточно описать интерфейс, общий для всех заменяемых методов, и описать собственно классы. Например, если мы хотим сделать заменяемым метод создания экземпляра автомобиля, то код для этого будет выглядеть так: /* * интерфейс для всех объектов-стратегий по созданию автомобиля */ interface CarCreator { Car invoke(); } class LandRoverCreator implements CarCreator { public Car invoke() { … } } class LamborghiniCreator implements CarCreator { public Car invoke() { … } } … class CustomFactory implements AbstractCarFactory { private CarCreator creator; public CarCreator getCreator() { return this.creator; } public void setCreator(CarCreator creator) { this.creator = creator; } public Car createFastest() { return this.creator.invoke(); } … } Посредством метода setCreator мы можем менять у данной фабрики метод, которым она создаёт самую быструю машину, при этом не прибегая к наследованию. В Java мы также можем использовать для создания «стратегических» объектов анонимные классы: public customizeFactory (CustomFactory factory) { CarCreator mycreator = new CarCreator() { public invoke() { … } } factory.setCreator(mycreator); … } В рамках Java-технологий этот паттерн можно встретить:
  • в тредах, которые реализуют единственный метод run;
  • в Swing все элементы управления реализуют интерфейс ActionListener с единственным методом actionPerformed;
  • при реализации интерфейса Comparator, например, для сортировки элементов массива и пр.
В нашем примере стратегию можно было бы также использовать при проектировании отношений между расходом топлива и скоростью автомобиля, при разработке стратегии рулевого механизма, для симуляции водителя и др. В других языках, где есть функции первого класса (first-class functions), этот паттерн может использоваться ещё чаще. Так, в функциональных языках особое распростронение получили функции map и reduce (fold), принимающие в качестве одного из параметров функцию (стратегию). Например, в Haskell сумму можно записать следующим образом: sum xs = foldl (+) 0 xs ghc> sum [1, 2, 3, 4, 5] 15 foldl – это функция свёртки. Она принимает на вход другую функцию или оператор от двух членов (здесь – оператор сложения) и применяет её сначала для второго своего параметра (в нашем примере - 0) и первого элемента списка, затем для результата этой функции и второго элемента списка и т.д., пока не закончатся все элементы. Интересным языком с точки зрения применения паттерна Стратегия является Oberon, где все методы объекта являются переменными. Связывание переменной и собственно методов происходит во время инициализации модуля путём простого присваивания. Это требует дополнительных затрат на хранение в каждом объекте ссылок на все методы, но зато даёт невероятную гибкость, поскольку вы можете свободно переопределить любой метод любого импортированного объекта, что практически избавляет от необходимости в наследовании.

Prototype, или Атака клонов

По определению, фабрика - это место массового производства. Наши CarFactory-s не являются исключением. Если фабрика представляет собой Singleton, то различные части системы могут постоянно запрашивать создание всё новых и новых экземпляров автомобилей, тем самым создавая эффект массового производства. А при массовом производстве любого продукта остро стоит вопрос о минимизации расходов на создание одного объекта. Рассмотрим процесс создания одного экземпляра. Он состоит из двух основных частей:
  1. выделение памяти под объект;
  2. инициализация.
Причём чаще всего наибольшее время занимает именно инициализация. В то же время, после инициализации получаются всегда идентичные «заводские» инстансы автомобилей. Избежать лишних инициализаций помогает паттерн Протитип (Prototype). Суть его проста: вместо производства нового объекты мы просто клонируем некий прототип, в котором уже проведена инициализация. В нашем примере фабрика может хранить у себя прототип автомобиля, который нужно произвести, и каждый раз просто отдавать его копию. Здесь есть один тонкий момент. Существует два типа клонирования объекта: поверхностное (shallow) и глубокое (deep). Поверхностное клонирование отвечает просто за копирование всех полей одного объекта в поля другого. Если поле является ссылкой, то копируется только сама ссылка. При глубоком же клонировании кроме полей самого объекта копируются ещё и все объекты, на которые он ссылактся. Схематично это выглядит так: Shallow and deep clones Java предлагает разработчикам довольно странный подход к клонированию. Во-первых, класс Object имеет метод clone(), но объявлен этот метод с модификатором protected. То есть, предполагается, что разработчик сам переопределит его под себя. Признаком того, что некий объект может быть клонирован, выступает интерфейс Cloneable. Интересно, что сам метод clone() в этом интерфейсе не описан. Присутствие Cloneable в объявлении класса является чем-то вроде маркера для метода Object.clone(), разрешающим field-by-field копирование. При отсутствии этого маркера метод бросит исключение CloneNotSupportedException. Для того чтобы наши автомобили поддерживали клонирование, введём дополнительный абстрактный класс CarImpl, реализующий нужный функционал: public abstract class CarImpl implements Car, Cloneable { public Object clone() { super.clone(); } … } Теперь все наши автомобили будут просто наследоваться от этого класса, и при необходимости смогут добавлять нужный для глубокого клонирования функционал: class Ford extends CarImpl { public Ford clone() { Ford clone = (Ford)super.clone(); // add to clone copies of referenced objects return clone; } } Концепции клонирования в разных языках могут сильно отличаться. Например, в C# существует метод MemberwiseClone(), выполняющий поверхностное клонирование, и интерфейс ICloneable, с объявленным методом Clone(), что, на мой взгляд, более логично. Также есть подходы к клонированию через сериализацию объекта. Ну и, конечно же, в языках с прямым доступом к памяти, таким как C++, клонирование может быть осуществлено с помощью операции прямого копирования блока памяти (memcpy).
1 - о нём речь пойдёт дальше в этой статье. 2 - это пример паттерна Прототип (Prototype). 3 - не путайте лисповские символы с элементарными частями строки (char) и с символами в Ruby (где они равнозначны ключевым словам в CL). В данном случае символ означает имя некоторого объекта, например, имя класса, который нужно инстанциировать. 4 - о декорировании функций речь пойдёт в 3-ей статье цикла. 5 - Haskell – один из самых математизированных языков программирования, поэтому данная конструкция, как и паттерн Абстрактная Фабрика, является выражением математического понятия «алгебраический тип данных». 6 - в Java 7, правда, обещают ввести специальный тип для создания ссылок на методы - MethodHandle.
Обсуждение