Перевод статьи: Perl Design Patterns
Автор: Фил Кроу (Phil Crow)
В 1995 году была опубликована книга "Шаблоны проектирования". В последующие годы она оказала значительное влияние на способ написания программ множеством разработчиков. В этой серии статей я рассмотрю книгу "Шаблоны проектирования" (так же называемую "книгой банды четырёх") и её философию применительно к Perl. Поскольку Perl - это объектно-ориентированный язык, то в нём можно напрямую применять примеры из книги банды четырёх. Однако, многие из проблем, которые пытается решить банда четырёх, лучше решаются способом, характерным для Perl, с использованием возможностей, не доступных разработчикам на Java или C++ и ориентированных исключительно на использование объектов. Даже в тех случаях, когда разработчики на других языках желают воспользоваться процедурными методиками, они не могут воспользоваться чрезвычайно мощной встроенной поддержкой шаблонов, которая имеется, например, в Perl.
Хотя эти статьи самодостаточны, вы можете узнать больше, если познакомитесь с книгой банды четырёх (а лучше держать её открытой в процессе чтения). Если у вас нет книги, попробуйте поискать в сети - многие люди обсуждают эти шаблоны. Поскольку в сети и в книге есть диаграммы объектов, соответствующие шаблонам, я не привожу их тут, но могу указать вам на подходящий сайт.
Я покажу, как реализовать наиболее значимые шаблоны с наиболее полным использованием богатых возможностей языкового ядра Perl. Я даже приведу примеры некоторых объектов.
Перед реализацией объектов необходимо понимать основы объектной системы Perl. Вы можете изучить её в печатных источниках, например, в Книге рецептов Perl Тома Кристиансена (Tom Christiansen) и Нэта Торкингтона (Nat Torkington) или по книге Объектно-ориентированный Perl Дэмиана Конуэя (Damian Conway). Но простейший способ изучить основы - это perldoc perltoot.
В качестве обоснования моего подхода позвольте мне начать с небольшой объектно-ориентированной философии. Вот два моих основных принципа объектов:
Позвольте мне кратко обосновать эти принципы.
Если вы работаете в компании по прокату машин (как я), имеет смысл представить в виде объекта договор проката. Данные по договору сильно связаны с методами, необходимыми для операций с ним. Для подсчёта платы за прокат вы можете использовать различные коэффициенты и складывать их вместе и т.п. Это подходящий случай для того, чтобы воспользоваться объектом (или скорее - совокупностью нескольких объектов).
Рассмотрим несколько примеров из других языков. В Java есть класс java.lang.Math. Он содержит, например, функции sine и cosine. Он содержит только методы класса и пару констант класса. Их не стоило насильно вписывать в объектно-ориентированные рамки, поскольку не существует объектов Math. Лучше было бы поместить эти функции в ядро, полностью удалив или сделать простые не объектно-ориентированные функции. Последняя возможность в Java недоступна в принципе.
Или обратимся к стандартной библиотеке шаблонов C++. Для того, чтобы сделать C++ обратно совместимым с C и сохранить возможность строгой статической проверки типов, необходим целая система шаблонов. То, что должно являться частью самого языкового ядра, существует в виде неуклюжих объектно-ориентированных конструкций. Почему бы в язык для начала просто не ввести более удобный тип для массива? Затем можно было бы заняться такими общеизвестными структурами данных, как стеки, очереди, двусвязные очереди и многими другими структурами данных, которые изучают в школе.
В частности, я не поддерживаю один из подходов банды четырёх: превращение идеи в полномасштабный класс объектов. Я предпочитаю подход Perl - внедрение наиболее важных концепций в ядро языка. Поскольку я предпочитаю подход Perl, я не буду показывать, как превратить в объект то, что гораздо проще представить в виде хэша без методов или в виде простых функций без класса. Я использую подход, обратный подходу банды четырёх: реализую полномасштабные шаблонные классы с использованием более простых концепций Perl.
Шаблоны в этой первой статье главным образом основаны на использовании встроенных возможностей Perl. В последующих статьях я рассмотрю другие группы шаблонов. Теперь, когда я объяснил, что я буду делать, можно приступать.
Есть множество структур, которые бывает нужно обойти поэлементно. Это могут быть массивы, ключи хэшей и элементы более сложных структур, например, узлы дерева.
Банда четырёх предполагает использование описанного выше приёма: превратить концепцию в объект. Это означает, что вы должны сделать итератор объектом. Каждый класс объекта, который нужно обходить должен иметь метод, возвращающий объект-итератор. Этот объект должен быть единообразным. Например, рассмотрим следующий код, в котором итератор используется для обхода ключей хэша в Java.
for (Iterator iter = hash.keySet().iterator(); iter.hasNext();) { Object key = iter.next(); Object value = hash.get(key); System.out.println(key + "\t" + value); }
Объект HashMap содержит элементы, которые можно обойти - это его ключи. Вы можете запросить их с помощью метода, возвращающего множество ключей - keySet. Это множество предоставляет вам объект-итератор Iterator, если вызвать его метод iterator. Iterator отвечает на запрос hasNext истиной, если ещё остались элементы для обхода и ложью, если элементы закончились. Метод next возвращает следующий объект из последовательности, которой управляет Iterator. С помощью этого объекта key, HashMap возвращает следующее значение в ответ на вызов get(key). Это аккуратно и чисто в полностью объектно-ориентированном языке с ограниченными операторами и встроенными типами. И это точный пример шаблона Итератор от банды четырёх.
В Perl любой встроенный или пользовательский объект, который можно обойти, имеет метод, возвращающий упорядоченный список элементов для обхода. Чтобы обойти список, его нужно просто поместить внутрь скобок цикла foreach. Поэтому пример выше для обхода хэша в Perl будет выглядеть следующим образом:
foreach my $key (keys %hash) { print "$key\t$hash{$key}\n"; }
Я мог бы реализовать этот шаблон в точном соответствии с диаграммой банды четырёх, но в Perl для этого есть более подходящий способ. В Perl 6 появится возможность вернуть "ленивый" список, элементы которого будут вычисляться по мере необходимости, так что приведённый выше код станет более эффективным, чем сейчас. В Perl 5 при вызове keys список ключей создаётся целиком. В будущем список ключей будет создаваться при необходимости, в большинстве случаев экономя память и время, если цикл закончился досрочно1.
Встроенная поддержка итераций в ядре языка свидетельствует о качестве дизайна Perl. Вместо неуклюжего механизма без поддержки ядром языка, как в Java и C++ (который, однако, есть в стандартной библиотеке шаблонов), Perl содержит этот шаблон в ядре языка. Как было сказано во введении, это принцип Perl:
Если шаблон по-настоящему важен, он должен являться частью ядра языка.
Пример выше использует ядро языка. Чтобы убедиться, что foreach полностью реализует шаблон итератор, даже для пользовательских модулей, рассмотрим пример из CPAN: XML::DOM. DOM для XML был определён Java-программистами. Один из методов, который вы можете вызвать для обработки DOM-документа, это getElementsByTagName. В спецификации DOM он возвращает NodeList, являющийся коллекцией Java. Однако, NodeList работает подобно Set в Java-коде из примера выше. Вы должны попросить его вернуть Итератор, а затем обойти его.
Если Perl-программист реализует DOM, он безусловно сделает так, чтобы getElementsByTagName возвращал обычный список Perl. Чтобы обойти список, он скажет что-то вроде:
foreach my $element ($doc->getElementsByTagName("tag")) { # ... обработать элемент ... }
Это разительно отличается от излишне многословного варианта Java:
NodeList elements = doc.getElementsByTagName("tag"); for (Iterator iter = elements.iterator(); iter.hasNext();) { Element element = (Element)iter.next(); // ... обработать элемент ... }
Одно из достоинств Perl заключается в возможности эффективно совмещать процедурный и объектно-ориентированный подходы с использованием возможностей ядра. На самом деле то, что банда четырёх предлагает реализовывать шаблон при помощи объектов и эти объекты нужны только в языках вроде Java, совершенно не означает, что программисты на Perl должны игнорировать не объектные возможности Perl.
Perl главным образом успешен в использовании принципа содействия. Основные шаблоны встроены в ядро языка. Полезные вещи реализованы в модулях. Бесполезные обычно отсутствуют.
Так, шаблон итератора от банды четырёх является частью ядра Perl, о чём часто забывают. Над реализацией следующего шаблона придётся немного поработать.
При обычной работе декоратор оборачивает объект, реализуя тот же интерфейс, что и обёрнутый объект. Например, предположим я добавил декоратор сжатия к объекту для записи файлов. Вызывающий код передаёт объект для записи файла конструктору декоратора и осуществляет запись в декоратор. Метод записи из декоратора сначала сжимает данные, а затем вызывает метод записи из обёрнутого объекта, осуществляющего запись. Любой другой тип объекта для записи файлов может быть обёрнут тем же декоратором, если все объекты для записи файлов реализуют один и тот же интерфейс. Декораторы можно выстраивать в цепочку. Текст может быть преобразован из ASCII в Unicode одним декоратором, а сжат другим. Порядок декораторов в цепочке имеет значение.
В Pel это можно сделать при помощи объектов, но можно также воспользоваться возможностями языка для реализации большинства необходимых мне декораций, иной раз обращаясь исключительно ко встроенному синтаксису.
Ввод-вывод - наиболее частое применение декораторов. Perl позволяет реализовать декорацию ввода-вывода напрямую. Рассмотрим пример, приведённый выше: сжатие в процессе записи. Есть два способа сделать это.
При открытии файла на запись в Perl можно воспользоваться средствами декорации из оболочки. Вот код примера рассмотренного выше:
open FILE, "| gzip > output.gz" or die "Не могу открыть gzip и/или output.gz: $!\n";
Теперь всё, что я пишу, пропускается через gzip перед тем, как попасть в output.gz. Этот способ пригоден до тех пор (1), пока вам хочется использовать оболочку, что иногда может оказаться не безопасным; и (2) в оболочке есть инструменты для того, что вам нужно сделать. Также внушает беспокойство вопрос об эффективности. Операционная система порождает новый процесс на шаге gzip. Создание процесса - это одна из самых медленных операций операционной системы, не являющейся операцией ввода-вывода.
Если нужно больше контроля над процессом обработки данных, тогда можно задекорировать его самостоятельно, при помощи механизма Perl под названием tie. Он быстрее, проще в использовании и мощнее в Perl 6, но работает и в Perl 5. Он работает с использованием объектно-ориентированной системы Perl; обратитесь к странице perltie за более подробной информацией.
Предположим, что нужно предварить отметкой времени каждую строку, выводимую в дескриптор. Вот связанный (tied) класс, который это делает.
package AddStamp; use strict; use warnings; sub TIEHANDLE { my $class = shift; my $handle = shift; return bless \$handle, $class; } sub PRINT { my $handle = shift; my $stamp = localtime(); print $handle "$stamp ", @_; } sub CLOSE { my $self = shift; close $self; } 1;
Это минимальный класс, в действительности может понадобиться дополнительный код, чтобы сделать декоратор более завершённым. Например, в коде выше не осуществляется проверка возможности записи в дескриптор, не проверяется, что он реализует PRINTF, поэтому вызов printf может завершиться ошибкой. Не стесняйтесь подумать о деталях. (Опять же, обратитесь к perldoc perltie за более подробной информацией.)
Вот несколько вещей, которые необходимо сделать. Конструктор связанного (tied) класса дескриптора файла называется TIEHANDLE. Его имя фиксировано и состоит из букв верхнего регистра, потому что Perl будет его вызывать. Это метод класса, поэтому его первый аргумент - имя класса. Другой аргумент - дескриптор, открытый на запись. Конструктор просто "благословляет" (bless) ссылку на дескриптор и возвращает её.
Метод PRINT принимает объект, сконструированный TIEHANDLE, и все аргументы, которые нужно напечатать. Он подсчитывает отметку времени и отправляет их вместе с исходными аргументами в дескриптор, используя настоящую функцию печати. Именно так работает декоратор. Декорированный объект позволяет печатать точно так же, как обычный дескриптор. Он выполняет небольшую работу, затем вызывает такой же метод из обёрнутого объекта.
Метод CLOSE закрывает дескриптор. Я использую наследование от Tie::StdHandle, чтобы получить этот метод и многие другие.
Поскольку я поместил AddTimeStamp.pm в каталог, входящий в последовательность поиска библиотек, я могу воспользоваться им таким образом:
#!/usr/bin/perl use strict; use warnings; use AddStamp; open LOG, ">output.tmp" or die "Не могу записать output.tmp: $!\n"; tie *STAMPED_LOG, "AddStamp", *LOG; while (<>) { print STAMPED_LOG; } close STAMPED_LOG;
После обычного открытия файла на запись я использую встроенную функцию для связывания дескриптора LOG с классом AddStamp под именем STAMPED_LOG. После этого я обращаюсь исключительно к STAMPED_LOG.
Если есть другой декоратор связывания, тогда можно передать связанный дескриптор ему. Единственный недостаток заключается в том, что Perl 5 работает со связанными объектами медленнее, чем с обычными. Ещё, по моему опыту, диски и сеть - это наиболее частые узкие места, поэтому не стоит обращать внимание на неэффективность использования памяти. В любом случае, если я ускорю скрипт на 90 процентов, я не сэкономлю много времени, потому что скрипт изначально работал не долго.
Такой подход работает для многих встроенных типов: скаляров, массивов, хэшей, как и для дескрипторов файлов. В документе perltie объясняется, как применять связывание к каждому из них.
Связывание великолепно, поскольку оно не требует, чтобы вызывающий код понимал, что происходит за кулисами. Это также справедливо и для декораторов банды четырёх, за одним понятным исключением: в Perl можно изменять поведение встроенных типов.
Одна из наиболее частых задач в Perl - это какое-либо преобразование списков. Возможно понадобится пропустить все элементы списка, начинающиеся с подчёркивания. Возможно потребуется отсортировать список или обратить порядок его элементов. Многие встроенные функции являются фильтрами списков. Они похожи на фильтры Unix, которые ожидают строки данных на стандартном потоке ввода, которые они каким-то образом преобразуют, перед тем как отправить результат на стандартный поток вывода. Как и в Unix, фильтры списков в Perl могут быть объединены в цепочку. Например, предположим, что нужен список всех подкаталогов в текущем каталоге в обратном алфавитном порядке. Вот одно из возможных решений:
#!/usr/bin/perl use strict; use warnings; opendir DIR, ".", or die "Не могу прочитать этот каталог, как мы сюда попали?\n"; my @files = reverse sort map { -d $_ ? $_ : () } readdir DIR; closedir DIR; print "@files\n";
В Perl 6 появился более понятный способ записи для этих операций, но при небольшом усилии можно научиться читать их и в Perl 5. Одна из интересных строк - шестая. Начните читать её справа (пользователи Unix воспримут это как обратный порядок). Сначала происходит чтение каталога. Поскольку map ожидает получить список, readdir возвращает список всех файлов в каталоге. map создаёт список с именем каждого файла, который является каталогом (или undef, если проверка -d не пройдена). sort упорядочивает список в порядке, подобном алфавитному, но использует вместо алфавита таблицу ASCII-кодов. reverse переставляет элементы списка в обратном порядке. Результат сохраняется в @files для последующей печати.
Можно очень легко создать собственный фильтр списков. Предположим, что нужно заменить уродливое использование map в примере выше (я считаю, что map всегда уродлив) на функцию специального назначения, вот так:
#!/usr/bin/perl use strict; use warnings; sub dirs_only (@) { my @retval; foreach my $entry (@_) { push @retval, $entry if (-d $entry); } return @retval; } opendir DIR, "." or die "Не могу прочитать этот каталог, как мы сюда попали?\n"; my @files = reverse sort { lc($a) cmp lc($b) } dirs_only readdir DIR; closedir DIR; local $" = ";"; print "@files\n";
Новая подпрограмма dirs_only заменяет map из предыдущего примера, пропуская нежелательные элементы списка.
Теперь в sort явным образом прописана подпрограмма сравнения. Это позволяет избежать ошибочной мысли о том, что dirs_only - это подпрограмма сравнения. Поскольку я вписал сравнение, я решил извлечь пользу из сложившейся ситуации и сортирую более точно, игнорируя регистр символов.
Можно создавать любые фильтры списков, которые придутся вам по душе.
Теперь я покажу наиболее значимые типы декораций. Любые другие нужно будет реализовывать способом, обычным для банды четырёх.
Следующий шаблон кажется похожим на мошенничество, но вообще-то Perl довольно часто производит такое впечатление.
Мысль о повторном использовании объектов - это сущность шаблона "приспособленец". Благодаря Марку-Джейсону Доминусу (Mark-Jason Dominus), Perl заходит в этой идее дальше, чем предполагает банда четырёх. Кроме того, он сделал эту работу один раз и для всех. Ларри Уоллу (Larry Wall) нравится эта идея настолько сильно, что он продвигает её в ядро Perl 6 (речь идёт о продвижении самой концепции).
Что я хочу:
Для объектов, чьи экземпляры не важны (они постоянны или случайны), запрос нового объекта должен возвращать тот же объект, который уже был создан ранее, если это возможно.
Этот шаблон совершенно не подходит, если нужны раздельные экземпляры объектов. Но если они не нужны, тогда с помощью таких объектов можно сэкономить время и память.
Вот пример того, как это работает в Perl. Предположим, что я хочу предоставить класс die для игр вроде Монополии или Костей. Мой класс die может выглядеть следующим образом: (Предупреждение: Это надуманный пример, имеющий целью проиллюстрировать приём.)
package CheapDie; use strict; use warnings; use Memoize; memoize('new'); sub new { my $class = shift; my $sides = shift; return bless \$sides, $class; } sub roll { my $sides = shift; my $random_number = rand; return int ($random_number * $sides) + 1; } 1;
На первый взгляд, этот класс похож на множество других классов. Он имеет конструктор под именем new. Конструктор сохраняет принятое значение в лексической переменной подпрограммы (так же известной как my-переменная), возвращая "благославлённую" ссылку на неё. Метод roll генерирует случайное число, умножает его на количество граней и возвращает результат.
Всё удивительное заключается в этих двух строчках:
use Memoize; memoize('new');
Они используют выдающиеся возможности Perl. Вызов функции memoize изменяет таблицу функций вызывающего модуля таким образом, что new оказывается обёрнутой. Обёртка функции проверяет входящие аргументы (в данном случае - количество граней). Если обёртка до этого не встречала таких аргументов, она вызовет функцию, запрошенную пользователем, сохранит результат в кэш и вернёт его пользователю. На это потребуется больше времени и памяти, чем если бы этот модуль не использовался.
Экономия начинается при повторном вызове. Когда обёртка будет вызвана с теми же самыми аргументами, которые использовались до этого, она не станет вызывать метод. Вместо этого она отправит кэшированный объект. У нас нет ничего необычного в вызывающем коде или в реализации объекта. Если объект большой или медленно создаётся, тогда эта техника сэкономит время и память. В нашем случае, она зря потратит и время и память, потому что объект слишком мал.
Единственное, о чём следует помнить - о том, что некоторые методы не выигрывают от применения этого приёма. Например, если вызвать memoize для roll, то она каждый раз будет возвращать одно и то же число, что не будет ожидаемым результатом.
Отметим, что Memoize может использоваться в случаях, где может и не быть объектов - на самом деле в его документации не предусматривается его использование в качестве фабрик объектов.
Мало того, что в языках подобных Java нет встроенных функций для кэширования результатов вызова методов, они вообще не позволяют талантливым пользователям реализовать такие функции. Марк-Джейсон Доминус (Mark-Jason Dominus) великолепно с этим справился, реализовав Memoize, но Ларри Уолл (Larry Wall) сделал большее, дав ему такую возможность. Представьте, что Java позволяет пользователю написать класс, манипулирующий таблицей символов вызывающего модуля во время работы. Мне кажется, что я уже слышу вопли ужаса. Конечно, такие приёмы могут привести к злоупотреблениям, но мы бы потеряли не много, поскольку мы всегда можем отказаться от дурного кода менее талантливых программистов, которые ошибаются при редактировании таблицы символов.
В Perl такие вещи вполне законны, но некоторые из них лучше оставить для модулей с сильным сообществом разработчиков. Это позволяет обычным пользователям получать преимущества от волшебства, не беспокоясь о том, чтобы заставить работать собственное волшебство. Memoize - это образец. Вместо создания собственных обёрток для вызовов и схемы кэширования, воспользуйтесь хорошо протестированной обёрткой, идущей в комплекте с Perl (и посмотрите свойство 'is cached' - кэшируется, чтобы сделать подобное с подпрограммами в Perl 6).
Следующий шаблон относится к этому, так что можно воспользоваться шаблоном "приспособленец" для его реализации.
В шаблоне "приспособленец" мы узнали, что существуют некоторые ресурсы, которые являются общими. Банда четырёх выделяет особый случай, когда есть всего один ресурс, который должен быть общим для всех. Это шаблон "одиночка". Предположим, что этим ресурсом является хэш, который содержит параметры конфигурации. Кто угодно должен иметь возможность заглянуть в этот хэш, но он должен быть создан при запуске (и может быть пересоздан по некоторому сигналу).
В большинстве случаев можно просто воспользоваться Memoize. Мне это кажется наиболее разумным. (Смотрите выше раздел "приспособленец".) В этом случае каждый, кто хочет получить доступ к ресурсу, вызывает конструктор. Первое же обращение к конструктору повлечёт за собой создание объекта, который и будет возвращён. Последующие вызовы конструктора будут возвращать объект, созданный при первом вызове конструктора.
Есть много других способов достичь того же результата. Например, если вы предполагаете, что вызывающий код может передать неожиданные аргументы, тогда Memoize будет создавать множество экземпляров, по одному на каждый набор аргументов. В этом случае есть смысл воспользоваться модулями управления одиночек, например, такими как Cache::FastMemoryCache на CPAN. Вы также можете воспользоваться переменными в области видимости файла, назначая им значение в блоке BEGIN. Помните, что bless не может использоваться в методе. Вы могли бы написать так:
package Name; my $singleton; BEGIN { $singleton = { attribute => 'value', another => 'something', }; bless $singleton, "Name"; } sub new { my $class = shift; return $singleton; }
Этот пример демонстрирует прямолинейный способ, который позволяет избежать некоторых накладных расходов Memoize. В этом примере я не учитывал возможность создавать подклассы. Может быть я должен был бы это сделать, но шаблон утверждает, что одиночка всегда принадлежит лишь одному классу. Это основополагающее утверждение об одиночках:
"Может существовать только один одиночка."2
Все четыре шаблона, рассмотренные в статье, используют встроенные возможности или стандартные модули. Итератор реализуется при помощи foreach. Декоратор реализуется при помощи перенаправления ввода-вывода Unix или при помощи связывания дескриптора файла. Для списков декораторы - это просто функции, которые получают и возвращают списки. Поэтому я мог бы назвать декораторы фильтрами. Приспособленцы - это общие объекты, которые легко реализуются при помощи модуля Memoize. Одиночки могут быть реализованы как приспособленцы или при помощи обычных объектов.
В следующий раз, если некоторые высокомерные объектно-ориентированные программисты заведут разговор о шаблонах, будьте уверены - вы знаете, как их использовать. Фактически, они встроены в ядро вашего языка (по крайней мере если подразумевать Perl)3.
В следующих статьях я рассмотрю шаблоны, основанные на контейнерах данных и ссылках на код.
Я написал эти статьи после прохождения курса банды четырёх от известной консалтинговой и тренинговой компании. Мои записки основаны на знаниях многих людей в сообществе Perl, включая Марка-Джейсона Доминуса (Mark-Jason Dominus), который продемонстрировал на YAPC 2002 свой талант обращения с итераторами в Perl. Хотя написанное тут принадлежит мне, меня вдохновили Доминус и многие другие члены сообщества Perl, в наибольшей степени - Ларри Уолл (Larry Wall), который встраивал шаблоны в сердце Perl в течение многих лет. Как раз за разом показывают эти шаблоны, Perl аккуратно и тщательно реализует принципы поощрения. Вместо добавления коллекций в виде исходных текстов модулей, как это сделано в Java и C++, в Perl есть только два вида коллекций: массивы и хэши. Оба входят в ядро языка. Я думаю, что величайшая сила Perl заключается в том, что сообщество может выбирать, что должно войти в ядро, а что должно быть вынесено из него. Perl 6 делает Perl лишь более конкурентноспособным в войне идей проектирования языков4.